@everystack/cli 0.4.100 → 0.4.102
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 +91 -0
- package/package.json +3 -3
- package/src/cli/authz-baseline.ts +8 -4
- package/src/cli/authz-compile.ts +61 -7
- package/src/cli/authz-contract.ts +23 -14
- package/src/cli/authz-derive.ts +282 -30
- package/src/cli/authz-reconcile.ts +6 -4
- package/src/cli/commands/db-check.ts +40 -0
- package/src/cli/commands/db-generate.ts +10 -9
- package/src/cli/commands/db-plan.ts +30 -13
- package/src/cli/commands/db-pull.ts +153 -63
- package/src/cli/commands/db-reconcile.ts +22 -3
- package/src/cli/commands/db-sync.ts +6 -5
- package/src/cli/commands/db.ts +91 -8
- package/src/cli/commands/update.ts +3 -2
- package/src/cli/db-build.ts +1 -0
- package/src/cli/declared-derived.ts +3 -1
- package/src/cli/declared-diff.ts +4 -1
- package/src/cli/derived-apply.ts +116 -10
- package/src/cli/derived-grants.ts +4 -2
- package/src/cli/derived-introspect.ts +23 -3
- package/src/cli/derived-plan.ts +37 -1
- package/src/cli/derived-render.ts +33 -34
- package/src/cli/edge-plan.ts +57 -9
- package/src/cli/generated-artifacts.ts +22 -0
- package/src/cli/generated-names.ts +108 -0
- package/src/cli/host-url.ts +48 -27
- package/src/cli/index.ts +5 -5
- package/src/cli/lifecycle-canon.ts +37 -0
- package/src/cli/lifecycle-derived.ts +721 -0
- package/src/cli/lifecycle-pull.ts +177 -0
- package/src/cli/lifecycle-verify.ts +232 -0
- package/src/cli/migration-compile.ts +8 -3
- package/src/cli/migration-generate.ts +23 -3
- package/src/cli/model-map.ts +255 -0
- package/src/cli/model-render.ts +226 -76
- package/src/cli/plain-output.ts +198 -10
- package/src/cli/runbook/model-section.ts +8 -5
- package/src/cli/schema-compile.ts +35 -17
- package/src/cli/schema-diff.ts +166 -31
- package/src/cli/schema-fingerprint.ts +39 -6
- package/src/cli/schema-introspect.ts +64 -2
- package/src/cli/schema-source.ts +40 -9
- package/src/cli/schema-swap.ts +3 -2
- package/src/cli/state-apply.ts +10 -4
- package/src/cli/ts-template.ts +52 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,97 @@ 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.102] - 2026-10-23
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- db:doctor reports lifecycle rows past the purge window
|
|
12
|
+
- generated lifecycle functions carry manifests
|
|
13
|
+
- db:pull verifies generated lifecycle objects
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- restore uses set-based updates, not a subtransaction per row
|
|
18
|
+
- drop a replaced primary key before altering its columns
|
|
19
|
+
- match quoted trigger functions by parsed identifiers
|
|
20
|
+
- db:pull renders the owner read pair as owner
|
|
21
|
+
- the verifier leaves an author's own restore overload alone
|
|
22
|
+
- allow author after-delete lifecycle triggers
|
|
23
|
+
- detect constraint lifecycle trigger replacements
|
|
24
|
+
- the verifier compares the declared delete rules with the live policies
|
|
25
|
+
- an invalid --purge-after fails cleanly
|
|
26
|
+
- check lifecycle restrict edges once, after the whole cascade
|
|
27
|
+
- reconcile drops a removed trigger before the function it executes
|
|
28
|
+
- a DELETE issued from inside a trigger on a lifecycle table is a soft delete
|
|
29
|
+
- pull-verify names the older CLI's restrict pair as stale and points to db:apply
|
|
30
|
+
- pull-verify drops only a whole pg_catalog qualifier from a trigger WHEN
|
|
31
|
+
|
|
32
|
+
### Documentation
|
|
33
|
+
|
|
34
|
+
- state the lifecycle restrict contract, multi-row order and the lifecycle-column rule
|
|
35
|
+
- consumer triggers under a stamp or purge: qualified names, soft deletes, and the db:apply remedy for an older CLI's restrict pair
|
|
36
|
+
- an older CLI's restrict function may report a missing manifest instead of stale; both say db:apply
|
|
37
|
+
|
|
38
|
+
## [0.4.101] - 2026-10-22
|
|
39
|
+
|
|
40
|
+
### Added
|
|
41
|
+
|
|
42
|
+
- db:plan lists the functions, views and triggers db:apply will change
|
|
43
|
+
- update names the .sst files it read when it finds no HOST_URL
|
|
44
|
+
- external FK target, .references({ schema, table, column })
|
|
45
|
+
- F7a relation options; hasMany dependent sets the child FK ON DELETE
|
|
46
|
+
- relation display options and hasOne (F7b)
|
|
47
|
+
- db:generate writes the model map; db:check fails when it is stale (F8)
|
|
48
|
+
- the model map carries softDelete; the admin reads it instead of the preset's hand-written flag (A5)
|
|
49
|
+
- hasMany dependent 'cascade' is ON DELETE CASCADE on the child's foreign key (D1)
|
|
50
|
+
- lifecycle: true declares four generated columns (L1.1)
|
|
51
|
+
- lifecycle gets a reserved owner role and hides deleted rows from read roles (L1.2a)
|
|
52
|
+
- lifecycle triggers turn DELETE into a soft delete (L1.2b)
|
|
53
|
+
- lifecycle restore, for whoever may delete the row (L1.3)
|
|
54
|
+
- db:pull folds a lifecycle table back to lifecycle: true (L1.4)
|
|
55
|
+
- a lifecycle parent's delete soft-deletes its cascade children in the same batch (L2 + L6, P1.2)
|
|
56
|
+
- <t>_restore brings back the row's whole batch subtree on the root's authority (L3, P1.3)
|
|
57
|
+
- Restore on a lifecycle resource restores its batch and previews it first (P1.5)
|
|
58
|
+
- db:provision creates the reserved lifecycle role (P2.7)
|
|
59
|
+
- a NOT NULL column has no clear control (N6)
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- a disabled derived trigger is drift
|
|
64
|
+
- import a non-public FK target by its qualified name
|
|
65
|
+
- a plan that removes a function, view or trigger is not "Safe to apply"
|
|
66
|
+
- key the pulled set on qualified names
|
|
67
|
+
- db:pull --govern-roles round-trips custom-role policies and column grants (assoc F4)
|
|
68
|
+
- db:pull refuses a no-USING column policy and escapes live predicates (assoc F4 review)
|
|
69
|
+
- escape live text in db:pull's sql templates
|
|
70
|
+
- escape the defineSql fallback's DROP FUNCTION template
|
|
71
|
+
- db:pull refuses live names with no safe generated identifier or file name
|
|
72
|
+
- db:pull escapes live text in // comments and walls every write inside its directory
|
|
73
|
+
- a grant to a role named __proto__ is visible to db:plan, db:pull and the fingerprint
|
|
74
|
+
- db:pull never renders a policy that admits no row as an ability
|
|
75
|
+
- F3e review minors: catalog FK target, one target slot, derived refusal
|
|
76
|
+
- F3e re-review: fail closed on a changed FK target; unambiguous comment
|
|
77
|
+
- F7a review: derive dependent ON DELETE per set, never write a field spec
|
|
78
|
+
- F7b review: mark display relations in drizzle, withhold only where/limit
|
|
79
|
+
- F8 review: private SQL spellings, key collisions, T2 mock proof
|
|
80
|
+
- run state DDL under the canonical search_path; read non-public enums as qualified
|
|
81
|
+
- retype a column onto an enum through text, dropping the old default first
|
|
82
|
+
- the map publishes the soft-delete field key, not a boolean (A5 review)
|
|
83
|
+
- owned trigger functions deploy as a non-superuser; the lifecycle transition runs only on its own table (L1.2c)
|
|
84
|
+
- the lifecycle pull fold keeps an author's restore overload and governs the reserved role at adoption (L1.4 review)
|
|
85
|
+
- the lifecycle pull fold checks the reserved role's effective privileges and always names its unfolded grants (P1.0 review)
|
|
86
|
+
- db:plan changes a primary key, and refuses to mint what it cannot express (P2.8)
|
|
87
|
+
|
|
88
|
+
### Changed
|
|
89
|
+
|
|
90
|
+
- fake timers for the two polling suites; generate the default cert once
|
|
91
|
+
|
|
92
|
+
### Documentation
|
|
93
|
+
|
|
94
|
+
- name the one runtime-vs-generated Drizzle difference a parent dependent creates
|
|
95
|
+
- say which fingerprint inputs moved; mark the guard-only enum diff test
|
|
96
|
+
- state precisely which policies admit no row and that a column grant under one is revoked
|
|
97
|
+
|
|
7
98
|
## [0.4.100] - 2026-10-16
|
|
8
99
|
|
|
9
100
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.102",
|
|
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>",
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
"structured-headers": "1.0.1",
|
|
146
146
|
"tsx": "4.21.0",
|
|
147
147
|
"typescript": "5.9.3",
|
|
148
|
-
"@everystack/model": "0.4.
|
|
148
|
+
"@everystack/model": "0.4.23"
|
|
149
149
|
},
|
|
150
150
|
"peerDependencies": {
|
|
151
151
|
"@everystack/server": ">=0.4.0",
|
|
@@ -192,7 +192,7 @@
|
|
|
192
192
|
"react": "19.2.0",
|
|
193
193
|
"ts-jest": "29.4.9",
|
|
194
194
|
"zod": "3.25.67",
|
|
195
|
-
"@everystack/server": "0.4.
|
|
195
|
+
"@everystack/server": "0.4.38"
|
|
196
196
|
},
|
|
197
197
|
"scripts": {
|
|
198
198
|
"test": "jest",
|
|
@@ -83,7 +83,9 @@ export function buildStageBaseline(
|
|
|
83
83
|
exemptions: readonly GrantExemption[],
|
|
84
84
|
meta: { observedAt: string; fingerprint: string; fpVersion?: number },
|
|
85
85
|
): BaselineStage {
|
|
86
|
-
|
|
86
|
+
// Null-prototype: a grantee is a live role name. On a plain object `grantees['__proto__'] ??=`
|
|
87
|
+
// reads Object.prototype, and the writes below would land ON Object.prototype.
|
|
88
|
+
const grantees: Record<string, BaselineGrantee> = Object.create(null);
|
|
87
89
|
for (const e of exemptions) {
|
|
88
90
|
const g = (grantees[e.grantee] ??= { privileges: [], tables: [], write: false });
|
|
89
91
|
g.privileges = [...new Set([...g.privileges, ...e.privileges])].sort();
|
|
@@ -112,10 +114,10 @@ export function mergeBaseline(existing: AuthzBaseline | null, stage: string, ent
|
|
|
112
114
|
* itself would make the review signal worthless.
|
|
113
115
|
*/
|
|
114
116
|
export function renderBaseline(baseline: AuthzBaseline): string {
|
|
115
|
-
const stages: Record<string, BaselineStage> =
|
|
117
|
+
const stages: Record<string, BaselineStage> = Object.create(null);
|
|
116
118
|
for (const stage of Object.keys(baseline.stages).sort()) {
|
|
117
119
|
const s = baseline.stages[stage];
|
|
118
|
-
const grantees: Record<string, BaselineGrantee> =
|
|
120
|
+
const grantees: Record<string, BaselineGrantee> = Object.create(null);
|
|
119
121
|
for (const g of Object.keys(s.grantees).sort()) {
|
|
120
122
|
const { privileges, tables, write } = s.grantees[g];
|
|
121
123
|
grantees[g] = { privileges: [...privileges].sort(), tables: [...tables].sort(), write };
|
|
@@ -182,7 +184,9 @@ export function checkAgainstBaseline(
|
|
|
182
184
|
|
|
183
185
|
for (const grantee of Object.keys(observed).sort()) {
|
|
184
186
|
const now = observed[grantee];
|
|
185
|
-
|
|
187
|
+
// Own keys only: the recorded baseline is JSON.parse'd, so a missing `__proto__` entry would
|
|
188
|
+
// read Object.prototype — truthy — and wave an unbaselined role through.
|
|
189
|
+
const then = Object.hasOwn(recorded, grantee) ? recorded[grantee] : undefined;
|
|
186
190
|
if (!then) {
|
|
187
191
|
violations.push({
|
|
188
192
|
grantee,
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { isColumnAbility, type ModelDescriptor, type Ability } from '@everystack/model';
|
|
23
|
+
import { isColumnAbility, LIFECYCLE_OWNER_ROLE, type ModelDescriptor, type Ability } from '@everystack/model';
|
|
24
24
|
import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract';
|
|
25
25
|
import { parenthesizeOnce, isBareCall } from './authz-contract';
|
|
26
26
|
import { normalizeDeparsedExpr } from './deparse-normal';
|
|
@@ -284,17 +284,19 @@ const policyRole = (role: string): string => (isPseudoPublic(role) ? 'public' :
|
|
|
284
284
|
* deduplicated, matching the introspected contract.
|
|
285
285
|
*/
|
|
286
286
|
function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
|
|
287
|
-
|
|
287
|
+
// Null-prototype maps, as on the introspected side (assembleContract): a role is a key, and
|
|
288
|
+
// '__proto__' passes the role-name guard, so a plain object would lose it.
|
|
289
|
+
const out: Record<string, Record<string, Set<string>>> = Object.create(null);
|
|
288
290
|
for (const a of abilities) {
|
|
289
291
|
if (!isColumnAbility(a)) continue;
|
|
290
292
|
const role = granteeKey(a.condition.role ?? 'authenticated');
|
|
291
293
|
const verb = a.action === 'read' ? 'SELECT' : 'UPDATE';
|
|
292
|
-
const cols = ((out[role] ??=
|
|
294
|
+
const cols = ((out[role] ??= Object.create(null) as Record<string, Set<string>>)[verb] ??= new Set<string>());
|
|
293
295
|
for (const column of a.condition.columns!) cols.add(toSnakeCase(column));
|
|
294
296
|
}
|
|
295
|
-
const grants: Record<string, Record<string, string[]>> =
|
|
297
|
+
const grants: Record<string, Record<string, string[]>> = Object.create(null);
|
|
296
298
|
for (const [role, verbs] of Object.entries(out)) {
|
|
297
|
-
grants[role] =
|
|
299
|
+
grants[role] = Object.create(null);
|
|
298
300
|
for (const [verb, columns] of Object.entries(verbs)) grants[role][verb] = [...columns].sort();
|
|
299
301
|
}
|
|
300
302
|
return Object.keys(grants).length ? grants : undefined;
|
|
@@ -438,6 +440,12 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
438
440
|
? viaPredicate(via)
|
|
439
441
|
: null;
|
|
440
442
|
const sdGuard = sdCol ? softDeleteGuard(sdCol) : null;
|
|
443
|
+
// A lifecycle model hides deleted rows from every read policy but the lifecycle role's own.
|
|
444
|
+
// Unlike softDelete there is no owner escape: the guard is AND-ed into each SELECT policy as
|
|
445
|
+
// compiled. A `manage` role's ALL policy is NOT guarded, on purpose: it sees deleted rows (to
|
|
446
|
+
// restore or purge them), exactly as it does under softDelete.
|
|
447
|
+
const lifecycleCol = model.lifecycle ? 'deleted_at' : null;
|
|
448
|
+
const lifecycleGuard = lifecycleCol ? softDeleteGuard(lifecycleCol) : null;
|
|
441
449
|
|
|
442
450
|
// A "row-scoped" condition is either a direct owner or a transitive via.
|
|
443
451
|
const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
|
|
@@ -534,6 +542,9 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
534
542
|
name: string, command: PolicyCommand, roles: string[],
|
|
535
543
|
using: string | null, check: string | null,
|
|
536
544
|
): void => {
|
|
545
|
+
if (lifecycleGuard && command === 'SELECT' && !roles.includes(LIFECYCLE_OWNER_ROLE)) {
|
|
546
|
+
using = andPredicates(lifecycleGuard, using) ?? lifecycleGuard;
|
|
547
|
+
}
|
|
537
548
|
// Same normalization the live producer applies (authz-contract's `predicate`): a model
|
|
538
549
|
// pulled on one PostgreSQL major carries that major's deparse spelling in its predicates,
|
|
539
550
|
// and it must compare equal against a stage on another. One rule table, two producers.
|
|
@@ -823,6 +834,24 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
823
834
|
|
|
824
835
|
policies.sort((a, b) => a.name.localeCompare(b.name));
|
|
825
836
|
|
|
837
|
+
// The lifecycle restore function inlines these DELETE rules and runs them as the lifecycle
|
|
838
|
+
// owner. Check the text as compiled, whatever produced it (via, ownerExpr, where, sql): a
|
|
839
|
+
// subquery would read with that role's privileges, and current_user/session_user would not
|
|
840
|
+
// name the caller. defineModel refuses the common spellings first with friendlier messages.
|
|
841
|
+
if (model.lifecycle) {
|
|
842
|
+
for (const p of policies) {
|
|
843
|
+
if (p.command !== 'DELETE' && p.command !== 'ALL') continue;
|
|
844
|
+
const offender = /\bselect\b|\bcurrent_user\b|\bsession_user\b/i.exec(p.using ?? '')?.[0];
|
|
845
|
+
if (!offender) continue;
|
|
846
|
+
throw new Error(
|
|
847
|
+
`${table}: policy ${p.name} (${p.command}) uses ${offender} on a lifecycle: true model. Restore runs this delete rule `
|
|
848
|
+
+ `inside a SECURITY DEFINER function as the lifecycle owner, where a subquery reads with that role's privileges and `
|
|
849
|
+
+ `current_user/session_user do not name the caller. Use a predicate on the row's own columns or the request claims `
|
|
850
|
+
+ `(owner without ownerExpr, or a plain where).`,
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
826
855
|
return {
|
|
827
856
|
table,
|
|
828
857
|
// The FORCE axiom: FORCE iff the write principal is subject to its own RLS
|
|
@@ -841,6 +870,30 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
841
870
|
};
|
|
842
871
|
}
|
|
843
872
|
|
|
873
|
+
/**
|
|
874
|
+
* The table's DELETE rule per role: role -> the USING of every compiled DELETE or ALL policy
|
|
875
|
+
* naming that role, OR-ed (each parenthesized) when there are several. Read back from
|
|
876
|
+
* {@link compileTableContract}, so it is the policy rule by construction, never a second
|
|
877
|
+
* derivation. The lifecycle role is left out: it is the machinery, not an author's role.
|
|
878
|
+
* A policy with no USING admits nothing here, so it is skipped (fail closed).
|
|
879
|
+
*/
|
|
880
|
+
export function deleteRulePredicates(model: ModelDescriptor, opts: CompileOptions = {}): Record<string, string> {
|
|
881
|
+
const byRole = new Map<string, string[]>();
|
|
882
|
+
for (const p of compileTableContract(model, opts).policies) {
|
|
883
|
+
if ((p.command !== 'DELETE' && p.command !== 'ALL') || p.using == null) continue;
|
|
884
|
+
for (const role of p.roles) {
|
|
885
|
+
if (role === LIFECYCLE_OWNER_ROLE) continue;
|
|
886
|
+
byRole.set(role, [...(byRole.get(role) ?? []), p.using]);
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
const out: Record<string, string> = {};
|
|
890
|
+
for (const role of [...byRole.keys()].sort()) {
|
|
891
|
+
const preds = byRole.get(role)!;
|
|
892
|
+
out[role] = preds.length === 1 ? preds[0] : preds.map((p) => `(${p})`).join(' OR ');
|
|
893
|
+
}
|
|
894
|
+
return out;
|
|
895
|
+
}
|
|
896
|
+
|
|
844
897
|
/** The four DML privileges, sorted — the expansion of a `manage` ability. */
|
|
845
898
|
const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
|
|
846
899
|
|
|
@@ -885,7 +938,8 @@ function compileGrants(
|
|
|
885
938
|
abilities: readonly Ability[],
|
|
886
939
|
privileges: Record<string, readonly string[]> = {},
|
|
887
940
|
): Record<string, string[]> {
|
|
888
|
-
|
|
941
|
+
// Null-prototype, for the same reason as compileColumnGrants.
|
|
942
|
+
const grants: Record<string, Set<string>> = Object.create(null);
|
|
889
943
|
const add = (role: string, verbs: readonly string[]): void => {
|
|
890
944
|
const set = (grants[role] ??= new Set<string>());
|
|
891
945
|
for (const v of verbs) set.add(v);
|
|
@@ -907,7 +961,7 @@ function compileGrants(
|
|
|
907
961
|
for (const [role, privs] of Object.entries(privileges)) {
|
|
908
962
|
if (privs.length) add(granteeKey(role), privs);
|
|
909
963
|
}
|
|
910
|
-
const out: Record<string, string[]> =
|
|
964
|
+
const out: Record<string, string[]> = Object.create(null);
|
|
911
965
|
for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
|
|
912
966
|
return out;
|
|
913
967
|
}
|
|
@@ -713,7 +713,7 @@ export function assembleSequenceGrants(rows: readonly SequenceAclRow[]): Sequenc
|
|
|
713
713
|
for (const row of rows) {
|
|
714
714
|
const entry = bySequence.get(row.sequence)
|
|
715
715
|
?? {
|
|
716
|
-
sequence: row.sequence, table: row.table, grants:
|
|
716
|
+
sequence: row.sequence, table: row.table, grants: Object.create(null) as Record<string, string[]>,
|
|
717
717
|
// Omitted rather than `false` on a serial sequence, so a contract read before this
|
|
718
718
|
// field existed and one read after compare structurally equal.
|
|
719
719
|
...(truthy(row.is_identity) ? { identity: true } : {}),
|
|
@@ -766,15 +766,15 @@ export type DefaultAclScope = Record<string, Record<string, string[]>>;
|
|
|
766
766
|
|
|
767
767
|
/** Fold the per-privilege rows into { tables, sequences }, each schema → grantee → privileges. */
|
|
768
768
|
export function assembleDefaultAcls(rows: readonly DefaultAclRow[]): { tables: DefaultAclScope; sequences: DefaultAclScope } {
|
|
769
|
-
const tables: DefaultAclScope =
|
|
770
|
-
const sequences: DefaultAclScope =
|
|
769
|
+
const tables: DefaultAclScope = Object.create(null);
|
|
770
|
+
const sequences: DefaultAclScope = Object.create(null);
|
|
771
771
|
for (const row of rows) {
|
|
772
772
|
if (row.grantee == null || row.privilege == null) continue;
|
|
773
773
|
const scope = String(row.objtype) === 'S' ? sequences : String(row.objtype) === 'r' ? tables : null;
|
|
774
774
|
if (!scope) continue;
|
|
775
775
|
const schema = String(row.schema ?? '');
|
|
776
776
|
const grantee = String(row.grantee);
|
|
777
|
-
const byGrantee = scope[schema] ?? (scope[schema] =
|
|
777
|
+
const byGrantee = scope[schema] ?? (scope[schema] = Object.create(null));
|
|
778
778
|
byGrantee[grantee] = [...new Set([...(byGrantee[grantee] ?? []), String(row.privilege)])].sort();
|
|
779
779
|
}
|
|
780
780
|
return { tables, sequences };
|
|
@@ -796,8 +796,8 @@ export function parseSchemaAcl(text: string | null | undefined): Record<string,
|
|
|
796
796
|
const s = String(text).trim();
|
|
797
797
|
if (!s.startsWith('{') || !s.endsWith('}')) return null;
|
|
798
798
|
const body = s.slice(1, -1);
|
|
799
|
-
if (body.trim() === '') return
|
|
800
|
-
const out: Record<string, string[]> =
|
|
799
|
+
if (body.trim() === '') return Object.create(null);
|
|
800
|
+
const out: Record<string, string[]> = Object.create(null);
|
|
801
801
|
// Split items at top-level commas — a quoted region may contain commas.
|
|
802
802
|
const items: string[] = [];
|
|
803
803
|
let start = 0;
|
|
@@ -986,13 +986,19 @@ export interface ContractRows {
|
|
|
986
986
|
* empty allowlist a diff would never flag. Only SECURITY DEFINER functions are
|
|
987
987
|
* recorded: an INVOKER function runs as the caller under RLS and is not an
|
|
988
988
|
* authorization decision point; a SECDEF function runs above RLS and is.
|
|
989
|
+
*
|
|
990
|
+
* Every map keyed by a live name (role, schema) is `Object.create(null)`. On a plain object
|
|
991
|
+
* `grants['__proto__'] = x` sets the prototype and adds no key, and PostgreSQL allows a quoted
|
|
992
|
+
* role named "__proto__": its grants vanished from this read, so the plan, the fingerprint and
|
|
993
|
+
* the pull could not see them. A null-prototype object serializes and iterates exactly like a
|
|
994
|
+
* plain one, so an ordinary database reads byte-identically.
|
|
989
995
|
*/
|
|
990
996
|
export function assembleContract(rows: ContractRows): AuthzContract {
|
|
991
997
|
const tables = new Map<string, TableContract>();
|
|
992
998
|
for (const row of rows.rls) {
|
|
993
999
|
if (IGNORED_SCHEMAS.has(row.schema)) continue;
|
|
994
1000
|
const { table, rls } = rlsRowToDescriptor(row);
|
|
995
|
-
tables.set(table, { table, rls, grants:
|
|
1001
|
+
tables.set(table, { table, rls, grants: Object.create(null), policies: [] });
|
|
996
1002
|
}
|
|
997
1003
|
|
|
998
1004
|
// A grant or policy on a table the RLS enumerator didn't list (e.g. a view, excluded
|
|
@@ -1011,8 +1017,8 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
1011
1017
|
const { table, grantee, privilege, column } = columnGrantRowToDescriptor(row);
|
|
1012
1018
|
const t = tables.get(table);
|
|
1013
1019
|
if (!t) continue;
|
|
1014
|
-
const byGrantee = (t.columnGrants ??=
|
|
1015
|
-
const byPriv = (byGrantee[grantee] ??=
|
|
1020
|
+
const byGrantee = (t.columnGrants ??= Object.create(null) as Record<string, Record<string, string[]>>);
|
|
1021
|
+
const byPriv = (byGrantee[grantee] ??= Object.create(null) as Record<string, string[]>);
|
|
1016
1022
|
(byPriv[privilege] ??= []).push(column);
|
|
1017
1023
|
}
|
|
1018
1024
|
|
|
@@ -1025,7 +1031,7 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
1025
1031
|
const { table, role, privileges } = effectivePrivilegeRowToDescriptor(row);
|
|
1026
1032
|
const t = tables.get(table);
|
|
1027
1033
|
if (!t || privileges.length === 0) continue;
|
|
1028
|
-
(t.effectivePrivileges ??=
|
|
1034
|
+
(t.effectivePrivileges ??= Object.create(null) as Record<string, string[]>)[role] = [...privileges].sort();
|
|
1029
1035
|
}
|
|
1030
1036
|
}
|
|
1031
1037
|
|
|
@@ -1053,10 +1059,10 @@ export function assembleContract(rows: ContractRows): AuthzContract {
|
|
|
1053
1059
|
// is distinguishable from "we never looked".
|
|
1054
1060
|
let schemaAcls: Record<string, Record<string, string[]>> | undefined;
|
|
1055
1061
|
if (rows.schemaAcls) {
|
|
1056
|
-
schemaAcls =
|
|
1062
|
+
schemaAcls = Object.create(null) as Record<string, Record<string, string[]>>;
|
|
1057
1063
|
for (const row of rows.schemaAcls) {
|
|
1058
1064
|
if (IGNORED_SCHEMAS.has(row.schema)) continue;
|
|
1059
|
-
schemaAcls[row.schema] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ??
|
|
1065
|
+
schemaAcls[row.schema] = parseSchemaAcl(row.acl == null ? '{}' : String(row.acl)) ?? Object.create(null);
|
|
1060
1066
|
}
|
|
1061
1067
|
}
|
|
1062
1068
|
|
|
@@ -1212,9 +1218,12 @@ export function compareContracts(declared: AuthzContract, live: AuthzContract):
|
|
|
1212
1218
|
|
|
1213
1219
|
function diffGrants(table: string, d: TableContract, l: TableContract, out: DriftFinding[]): void {
|
|
1214
1220
|
const grantees = new Set([...Object.keys(d.grants), ...Object.keys(l.grants)]);
|
|
1221
|
+
// Own keys only: a declared contract loaded with JSON.parse is a plain object, where a
|
|
1222
|
+
// grantee the file lacks (`__proto__`, `constructor`) would read an Object.prototype member.
|
|
1223
|
+
const held = (grants: Record<string, string[]>, g: string): string[] => (Object.hasOwn(grants, g) ? grants[g] : []);
|
|
1215
1224
|
for (const g of grantees) {
|
|
1216
|
-
const dp = (d.grants
|
|
1217
|
-
const lp = (l.grants
|
|
1225
|
+
const dp = held(d.grants, g).join(',');
|
|
1226
|
+
const lp = held(l.grants, g).join(',');
|
|
1218
1227
|
if (dp !== lp) {
|
|
1219
1228
|
out.push({ subject: table, kind: 'grant', detail: `grant to ${g}: declared [${dp}], live [${lp}]` });
|
|
1220
1229
|
}
|