@everystack/server 0.4.28 → 0.4.29
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 +30 -0
- package/package.json +2 -2
- package/src/owner-adoption.ts +307 -0
- package/src/owner-enactment.ts +231 -0
- package/src/plugin.ts +44 -0
- package/src/provision.ts +271 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ All notable changes to `@everystack/server` are documented here.
|
|
|
4
4
|
|
|
5
5
|
Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
6
6
|
|
|
7
|
+
## [0.4.29] - 2026-09-04
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- ENACT core for brownfield ownership — PG16 guard + owner-grant router
|
|
12
|
+
- wire ENACT into runProvision + master-connection dep (B2, B3, BV)
|
|
13
|
+
- ADOPT semantic audit + confirm gate (B4a, B4c core)
|
|
14
|
+
- ADOPT reassignment planner + pre-flight (B4 core)
|
|
15
|
+
- wire ADOPT into runProvision — master-only, confirm-gated, one transaction (B4, B4c)
|
|
16
|
+
- wire the master connection into the db:provision ops action (B6, server half)
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- BLOCKER A — reorder ownership convergence AFTER the secret write
|
|
21
|
+
- BLOCKER B — master SELF-GRANTS SET on the declared owner (RDS split now works)
|
|
22
|
+
- BLOCKER C — the confirm gate reads LIVE facts, not declared
|
|
23
|
+
- round-2 BLOCKER-1 — run ownership convergence on the credentials no-op path too
|
|
24
|
+
|
|
7
25
|
## [0.4.28] - 2026-09-02
|
|
8
26
|
|
|
9
27
|
### Added
|
|
@@ -21,6 +39,18 @@ Format based on [Keep a Changelog](https://keepachangelog.com/).
|
|
|
21
39
|
|
|
22
40
|
- delete the orphaned recompile-descent helpers, rewire the illusion of coverage (F1)
|
|
23
41
|
|
|
42
|
+
## [0.4.27] - 2026-08-22
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- auto-generated changelogs from conventional commits
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- db:provision is additive-only — never reset an existing role's password
|
|
51
|
+
- address closing-gate review — pin ordering, guard half-done recovery
|
|
52
|
+
- address Fable red-team — rotated-stage guard, safe polarity
|
|
53
|
+
|
|
24
54
|
## [0.4.26] - 2026-08-15
|
|
25
55
|
|
|
26
56
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.29",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -187,7 +187,7 @@
|
|
|
187
187
|
"sst": "4.13.1",
|
|
188
188
|
"ts-jest": "29.4.9",
|
|
189
189
|
"typescript": "5.9.3",
|
|
190
|
-
"@everystack/cli": "0.4.
|
|
190
|
+
"@everystack/cli": "0.4.71",
|
|
191
191
|
"@everystack/auth": "0.4.4"
|
|
192
192
|
},
|
|
193
193
|
"scripts": {
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* owner-adoption — the ADOPT half of brownfield ownership convergence (Gap 2).
|
|
3
|
+
*
|
|
4
|
+
* A brownfield database's functions/views were created by the old tool AS the RDS master
|
|
5
|
+
* (`postgres`), not as the operator or the declared owner. The least-privilege operator cannot
|
|
6
|
+
* `CREATE OR REPLACE` an object it does not own, so db:reconcile refuses "must be owner". ADOPT
|
|
7
|
+
* reassigns each object the Models DECLARE to its declared owner — but this must run on the MASTER
|
|
8
|
+
* connection (PostgreSQL 16 `ALTER ... OWNER TO` needs the runner to be a member of the CURRENT
|
|
9
|
+
* owner, and giving the operator INHERIT membership in `postgres` would make it the master — the
|
|
10
|
+
* one thing the whole least-privilege design forbids).
|
|
11
|
+
*
|
|
12
|
+
* ADOPT IS A RUNTIME BEHAVIOR CHANGE, NOT COSMETIC HARDENING. A SECURITY DEFINER function owned by
|
|
13
|
+
* `postgres` executes with the master's rights; reassigning its owner flips the runs-as identity
|
|
14
|
+
* AT CALL TIME (not at ALTER time — the ALTER is always green). A view's underlying-table checks
|
|
15
|
+
* run as its owner. So ADOPT is gated (`--confirm` + a printed manifest) for exactly the objects
|
|
16
|
+
* whose semantics change, and the pre-adopt SEMANTIC AUDIT below flags where a capability is lost.
|
|
17
|
+
*
|
|
18
|
+
* THE AUDIT IS HEURISTIC BY NECESSITY, and says so. PostgreSQL does not track which tables an
|
|
19
|
+
* opaque function body (a SQL/plpgsql source string) references, so "check every referenced table"
|
|
20
|
+
* is not precisely realizable. What IS precise is the ATTRIBUTE DELTA — a superuser/BYPASSRLS
|
|
21
|
+
* current owner handing to an owner that is neither is a real, certain capability loss. The body
|
|
22
|
+
* regex and the schema-wide privilege gap are WARNINGS, not guarantees. The audit never claims an
|
|
23
|
+
* object is safe; it names what it can prove and warns where it cannot.
|
|
24
|
+
*
|
|
25
|
+
* This module is pure. runProvision wires the live catalog reads (role attributes, the privilege
|
|
26
|
+
* gap) and applies the reassignment in one transaction on the master.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** One object to (possibly) reassign. `body` is the function source, absent for views/matviews. */
|
|
30
|
+
export interface Adoptee {
|
|
31
|
+
/** Reassign target: `api.foo(integer)` for a function, `api.v_report` for a view/matview. */
|
|
32
|
+
identity: string;
|
|
33
|
+
schema: string;
|
|
34
|
+
name: string;
|
|
35
|
+
kind: 'function' | 'view' | 'materialized view';
|
|
36
|
+
/** SECURITY DEFINER — functions only; always false for a view/matview. */
|
|
37
|
+
secdef: boolean;
|
|
38
|
+
/** The live owner (e.g. `postgres`). */
|
|
39
|
+
currentOwner: string;
|
|
40
|
+
/** The owner the Models declare. */
|
|
41
|
+
declaredOwner: string;
|
|
42
|
+
body?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The cluster attributes of a role that decide what its owned objects can do. */
|
|
46
|
+
export interface RoleAttrs {
|
|
47
|
+
superuser: boolean;
|
|
48
|
+
bypassRls: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface AdoptionFinding {
|
|
52
|
+
/**
|
|
53
|
+
* capability-drop — PRECISE: a superuser current owner hands to a non-superuser; superuser-only
|
|
54
|
+
* body operations will fail at call time.
|
|
55
|
+
* rls-filtering — PRECISE: the runs-as identity loses RLS bypass; cross-owner reads may silently
|
|
56
|
+
* return filtered rows.
|
|
57
|
+
* superuser-op — WARNING: the body matches a superuser-only operation (regex, may false-positive).
|
|
58
|
+
* privilege-gap — WARNING: the declared owner lacks privileges on some app tables; an adopted
|
|
59
|
+
* object that touches one fails at call time (which tables it touches is not knowable here).
|
|
60
|
+
*/
|
|
61
|
+
severity: 'capability-drop' | 'rls-filtering' | 'superuser-op' | 'privilege-gap';
|
|
62
|
+
identity: string;
|
|
63
|
+
detail: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Body operations that need superuser and will fail as a non-superuser owner. A WARNING signal —
|
|
68
|
+
* a regex over the source, so it can match text in a comment or string. Never a guarantee; it
|
|
69
|
+
* points a human at a body to read, which is the audit Fable calls the real work.
|
|
70
|
+
*/
|
|
71
|
+
const SUPERUSER_OP_RE =
|
|
72
|
+
/\b(pg_terminate_backend|pg_read_file|pg_read_binary_file|pg_ls_dir|pg_stat_file|lo_import|lo_export|COPY\s+.*\s+FROM\s+PROGRAM|CREATE\s+ROLE|CREATE\s+USER|ALTER\s+SYSTEM)\b/i;
|
|
73
|
+
|
|
74
|
+
/** Only objects whose owner actually changes are adopted; the rest are already correct. */
|
|
75
|
+
export function needsReassignment(a: Adoptee): boolean {
|
|
76
|
+
return a.currentOwner !== a.declaredOwner;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The pre-adopt semantic audit. `attrs` carries the cluster attributes of every current and
|
|
81
|
+
* declared owner named. `tableGaps[owner]` lists app tables that owner cannot access at all — the
|
|
82
|
+
* informational "where a SECDEF body could fail" set, or undefined when not probed.
|
|
83
|
+
*
|
|
84
|
+
* Findings are ordered precise-first so a manifest leads with what is certain.
|
|
85
|
+
*/
|
|
86
|
+
export function auditAdoption(
|
|
87
|
+
adoptees: Adoptee[],
|
|
88
|
+
attrs: Record<string, RoleAttrs>,
|
|
89
|
+
tableGaps: Record<string, string[]> = {},
|
|
90
|
+
): AdoptionFinding[] {
|
|
91
|
+
const findings: AdoptionFinding[] = [];
|
|
92
|
+
for (const a of adoptees) {
|
|
93
|
+
if (!needsReassignment(a)) continue;
|
|
94
|
+
// A view/matview runs its underlying-table checks as its owner; a SECDEF function runs its
|
|
95
|
+
// whole body as its owner. An INVOKER function runs as its caller, so its owner change carries
|
|
96
|
+
// no runtime semantics — it is reassigned, but not audited for capability loss.
|
|
97
|
+
const runsAsOwner = a.kind !== 'function' || a.secdef;
|
|
98
|
+
if (!runsAsOwner) continue;
|
|
99
|
+
|
|
100
|
+
const cur = attrs[a.currentOwner];
|
|
101
|
+
const decl = attrs[a.declaredOwner];
|
|
102
|
+
|
|
103
|
+
if (cur?.superuser && !decl?.superuser) {
|
|
104
|
+
findings.push({ severity: 'capability-drop', identity: a.identity,
|
|
105
|
+
detail: `runs-as flips from superuser '${a.currentOwner}' to '${a.declaredOwner}' — any superuser-only operation in the body fails at CALL time (the ALTER is green regardless).` });
|
|
106
|
+
}
|
|
107
|
+
if ((cur?.superuser || cur?.bypassRls) && !decl?.bypassRls) {
|
|
108
|
+
findings.push({ severity: 'rls-filtering', identity: a.identity,
|
|
109
|
+
detail: `loses RLS bypass ('${a.currentOwner}' had it, '${a.declaredOwner}' does not) — reads across owners may now return FILTERED rows silently, not an error.` });
|
|
110
|
+
}
|
|
111
|
+
if (a.body && SUPERUSER_OP_RE.test(a.body)) {
|
|
112
|
+
const m = a.body.match(SUPERUSER_OP_RE);
|
|
113
|
+
findings.push({ severity: 'superuser-op', identity: a.identity,
|
|
114
|
+
detail: `body references '${m?.[0]}', which needs superuser — review whether '${a.declaredOwner}' can run it (heuristic: this is a text match, verify by reading the body).` });
|
|
115
|
+
}
|
|
116
|
+
const gaps = tableGaps[a.declaredOwner];
|
|
117
|
+
if (gaps?.length) {
|
|
118
|
+
findings.push({ severity: 'privilege-gap', identity: a.identity,
|
|
119
|
+
detail: `'${a.declaredOwner}' lacks privileges on ${gaps.length} app table(s) (e.g. ${gaps.slice(0, 3).join(', ')}) — if this object touches one it fails at call time (which tables it touches is not knowable from the catalog).` });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return findings;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One object that will actually reassign, with the LIVE facts the confirm gate keys on. */
|
|
126
|
+
export interface Reassignment {
|
|
127
|
+
kind: Adoptee['kind'];
|
|
128
|
+
/** SECURITY DEFINER as the model DECLARES it. */
|
|
129
|
+
declaredSecdef: boolean;
|
|
130
|
+
/** SECURITY DEFINER as the object is RIGHT NOW (probed). A live-SECDEF/declared-INVOKER function
|
|
131
|
+
* still runs as its owner until db:reconcile replaces the body — so the gate must see this. */
|
|
132
|
+
liveSecdef: boolean;
|
|
133
|
+
declaredOwner: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whether ADOPT requires `--confirm`, keyed on LIVE facts (Fable BLOCKER C — the declared attribute
|
|
138
|
+
* is not enough). Confirm when a reassignment changes the runs-as identity or hands ownership to a
|
|
139
|
+
* powerful role:
|
|
140
|
+
* - a view or matview (its underlying-table checks run as the owner), OR
|
|
141
|
+
* - a function that is SECURITY DEFINER either as declared OR as it lives right now, OR
|
|
142
|
+
* - any object whose declared owner is LIVE superuser / BYPASSRLS (handing ownership escalates).
|
|
143
|
+
* A plain invoker function to an ordinary owner needs no confirm. Empty → false.
|
|
144
|
+
*/
|
|
145
|
+
export function requiresConfirm(reassignments: Reassignment[], attrs: Record<string, RoleAttrs> = {}): boolean {
|
|
146
|
+
return reassignments.some((r) => {
|
|
147
|
+
if (r.kind !== 'function') return true;
|
|
148
|
+
if (r.declaredSecdef || r.liveSecdef) return true;
|
|
149
|
+
const target = attrs[r.declaredOwner];
|
|
150
|
+
return !!(target?.superuser || target?.bypassRls);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Live catalog facts about one candidate object, read on the MASTER before any ALTER — the
|
|
156
|
+
* pre-flight that turns a mid-batch Postgres error into a precise refusal.
|
|
157
|
+
*/
|
|
158
|
+
export interface AdopteeProbe {
|
|
159
|
+
identity: string;
|
|
160
|
+
/** The object is present in the catalog. Absent → nothing to adopt (reconcile will create it). */
|
|
161
|
+
exists: boolean;
|
|
162
|
+
/** prokind/relkind matches the declared kind. `ALTER VIEW` on a matview ERRORS — refuse first. */
|
|
163
|
+
kindMatches: boolean;
|
|
164
|
+
/** The LIVE SECURITY DEFINER flag (pg_proc.prosecdef; false for views/matviews). The gate keys on
|
|
165
|
+
* this AND the declared flag — a live-SECDEF function declared INVOKER still flips runs-as now. */
|
|
166
|
+
liveSecdef: boolean;
|
|
167
|
+
/** The object's real current owner (trusted over the declared guess). */
|
|
168
|
+
liveOwner: string;
|
|
169
|
+
/** An extension owns this object (pg_depend deptype='e'). Its OID binds to the extension — never reassign. */
|
|
170
|
+
isExtensionMember: boolean;
|
|
171
|
+
/** The declared owner role exists (ADOPT never invents one). */
|
|
172
|
+
declaredOwnerExists: boolean;
|
|
173
|
+
/**
|
|
174
|
+
* PG16 `ALTER … OWNER TO` needs the runner (the master) to hold the privileges of the CURRENT
|
|
175
|
+
* owner (to give the object away) AND be able to SET ROLE to the DECLARED owner (to receive it).
|
|
176
|
+
* These are the two conjuncts, read separately so a refusal names the RIGHT one — and because they
|
|
177
|
+
* fail for different, differently-fixable reasons on a non-superuser RDS master.
|
|
178
|
+
*/
|
|
179
|
+
masterPrivsOfCurrent: boolean;
|
|
180
|
+
masterCanSetDeclared: boolean;
|
|
181
|
+
/**
|
|
182
|
+
* The master holds ADMIN OPTION on the declared owner (or is a superuser) — so when it cannot yet
|
|
183
|
+
* SET ROLE to it, ADOPT can self-grant `GRANT <declaredOwner> TO CURRENT_USER WITH SET TRUE` inside
|
|
184
|
+
* its own transaction rather than refuse. ENACT grants the declared owner to the OPERATOR, never
|
|
185
|
+
* the master, so on a genuine RDS split this self-grant is what makes the master able to adopt.
|
|
186
|
+
*/
|
|
187
|
+
masterAdminOfDeclared: boolean;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const OWNER_IDENT = /^[a-z_][a-z0-9_]*$/;
|
|
191
|
+
const KEYWORD: Record<Adoptee['kind'], string> = {
|
|
192
|
+
function: 'FUNCTION', view: 'VIEW', 'materialized view': 'MATERIALIZED VIEW',
|
|
193
|
+
};
|
|
194
|
+
const RELKIND: Record<Adoptee['kind'], string> = { function: 'f', view: 'v', 'materialized view': 'm' };
|
|
195
|
+
|
|
196
|
+
/** A SQL single-quoted literal, doubling embedded quotes. */
|
|
197
|
+
function lit(value: string): string {
|
|
198
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The live pre-flight probe, read on the MASTER. One row per adoptee via UNION ALL. Uses
|
|
203
|
+
* `to_regprocedure`/`to_regclass`, which return NULL (never an error) for an absent object, so the
|
|
204
|
+
* probe reports `exists=false` rather than throwing mid-batch.
|
|
205
|
+
*
|
|
206
|
+
* The two PG16 `ALTER … OWNER TO` conjuncts are read SEPARATELY (privs of the current owner; SET on
|
|
207
|
+
* the declared owner), plus whether the master holds ADMIN on the declared owner so ADOPT can
|
|
208
|
+
* self-grant the missing SET. A REAL superuser satisfies all three. On RDS nobody is a real
|
|
209
|
+
* superuser, so memberships decide it — read from the catalog, never assumed (Fable). The owner's
|
|
210
|
+
* CREATE on the schema is ENACT's precondition (ENACT runs before ADOPT), not re-checked here.
|
|
211
|
+
*/
|
|
212
|
+
export function adopteeProbeSql(adoptees: Adoptee[]): string {
|
|
213
|
+
const selects = adoptees.map((a) => {
|
|
214
|
+
const isFn = a.kind === 'function';
|
|
215
|
+
// `o` is the catalog row; `reg` resolves the OID without erroring on absence.
|
|
216
|
+
const regExpr = isFn ? `to_regprocedure(${lit(a.identity)})` : `to_regclass(${lit(a.identity)})`;
|
|
217
|
+
const catalog = isFn ? 'pg_proc' : 'pg_class';
|
|
218
|
+
const ownerCol = isFn ? 'o.proowner' : 'o.relowner';
|
|
219
|
+
const kindCol = isFn ? `(o.prokind = 'f')` : `(o.relkind = '${RELKIND[a.kind]}')`;
|
|
220
|
+
const superExpr = `(SELECT b.rolsuper FROM pg_roles b WHERE b.rolname = CURRENT_USER)`;
|
|
221
|
+
const decl = lit(a.declaredOwner);
|
|
222
|
+
return `
|
|
223
|
+
SELECT
|
|
224
|
+
${lit(a.identity)} AS identity,
|
|
225
|
+
(o.oid IS NOT NULL) AS exists,
|
|
226
|
+
COALESCE(${kindCol}, false) AS kind_matches,
|
|
227
|
+
COALESCE(${isFn ? 'o.prosecdef' : 'false'}, false) AS live_secdef,
|
|
228
|
+
COALESCE(pg_get_userbyid(${ownerCol}), '') AS live_owner,
|
|
229
|
+
COALESCE((SELECT true FROM pg_depend d WHERE d.objid = o.oid AND d.deptype = 'e' LIMIT 1), false) AS is_extension_member,
|
|
230
|
+
EXISTS (SELECT 1 FROM pg_roles WHERE rolname = ${decl}) AS declared_owner_exists,
|
|
231
|
+
COALESCE(${superExpr} OR pg_has_role(CURRENT_USER, ${ownerCol}, 'USAGE'), false) AS master_privs_of_current,
|
|
232
|
+
COALESCE(${superExpr} OR pg_has_role(CURRENT_USER, ${decl}, 'SET'), false) AS master_can_set_declared,
|
|
233
|
+
COALESCE(${superExpr} OR (SELECT m.admin_option FROM pg_auth_members m JOIN pg_roles r2 ON r2.oid = m.roleid
|
|
234
|
+
WHERE r2.rolname = ${decl} AND m.member = (SELECT oid FROM pg_roles WHERE rolname = CURRENT_USER)), false) AS master_admin_of_declared
|
|
235
|
+
FROM (SELECT ${regExpr} AS oid) reg
|
|
236
|
+
LEFT JOIN ${catalog} o ON o.oid = reg.oid`.trim();
|
|
237
|
+
});
|
|
238
|
+
return selects.join('\nUNION ALL\n');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface AdoptionPlan {
|
|
242
|
+
/** `ALTER ... OWNER TO ...` statements, applied on the master in ONE transaction. */
|
|
243
|
+
statements: string[];
|
|
244
|
+
/** The adoptees each statement moves, in order — for the manifest and the schema_log record. */
|
|
245
|
+
reassigned: Adoptee[];
|
|
246
|
+
/** Precise pre-flight refusals — nothing is applied if this is non-empty (all or nothing). */
|
|
247
|
+
refusals: string[];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Turn declared objects + their live probes into the reassignment plan. Only objects that EXIST
|
|
252
|
+
* and are mis-owned are moved; a declared object that is absent is left for reconcile to create
|
|
253
|
+
* owned correctly. Every object is checked — exists, kind, not-extension, declared owner present,
|
|
254
|
+
* master can act — BEFORE any statement, so the caller applies the whole set or refuses the whole
|
|
255
|
+
* set. A refusal names the exact obstacle; it is never a raw error from the middle of an ALTER.
|
|
256
|
+
*/
|
|
257
|
+
export function planAdoptionReassignment(adoptees: Adoptee[], probes: AdopteeProbe[]): AdoptionPlan {
|
|
258
|
+
const byId = new Map(probes.map((p) => [p.identity, p]));
|
|
259
|
+
const alters: string[] = [];
|
|
260
|
+
const selfGrants = new Set<string>(); // one per declared owner the master must self-grant SET on
|
|
261
|
+
const plan: AdoptionPlan = { statements: [], reassigned: [], refusals: [] };
|
|
262
|
+
|
|
263
|
+
for (const a of adoptees) {
|
|
264
|
+
const p = byId.get(a.identity);
|
|
265
|
+
if (!p || !p.exists) continue; // not in the DB — reconcile creates it owned correctly, nothing to adopt
|
|
266
|
+
if (p.liveOwner === a.declaredOwner) continue; // already correct
|
|
267
|
+
if (p.isExtensionMember) {
|
|
268
|
+
plan.refusals.push(`${a.identity}: owned by an extension — never reassigned (its OID binds to the extension).`);
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
if (!p.kindMatches) {
|
|
272
|
+
plan.refusals.push(`${a.identity}: declared as ${a.kind} but the live object is a different kind — refusing before ALTER ${KEYWORD[a.kind]} errors on it.`);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (!p.declaredOwnerExists) {
|
|
276
|
+
plan.refusals.push(`${a.identity}: declared owner '${a.declaredOwner}' does not exist — ADOPT never invents a role. CREATE ROLE ${a.declaredOwner} NOLOGIN; then re-run.`);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (!OWNER_IDENT.test(a.declaredOwner)) {
|
|
280
|
+
plan.refusals.push(`${a.identity}: invalid declared owner name ${JSON.stringify(a.declaredOwner)}.`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
// PG16 ALTER … OWNER TO needs BOTH conjuncts. Name the failing one precisely (the two fail for
|
|
284
|
+
// different reasons and have different fixes), and try to self-heal the SET conjunct.
|
|
285
|
+
if (!p.masterPrivsOfCurrent) {
|
|
286
|
+
plan.refusals.push(`${a.identity}: the master lacks the privileges of the current owner '${p.liveOwner}' — it cannot give the object away. As a role that can: GRANT ${p.liveOwner} TO <master>; then re-run.`);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
if (!p.masterCanSetDeclared) {
|
|
290
|
+
// ENACT grants the declared owner to the OPERATOR, not the master — so on the real RDS split
|
|
291
|
+
// the master arrives here without SET on the declared owner. If it holds ADMIN on that owner
|
|
292
|
+
// it self-grants SET inside the transaction; otherwise it is a precise refusal naming THIS
|
|
293
|
+
// conjunct (not the current-owner one), with the fix.
|
|
294
|
+
if (p.masterAdminOfDeclared) {
|
|
295
|
+
selfGrants.add(`GRANT ${a.declaredOwner} TO CURRENT_USER WITH SET TRUE, INHERIT FALSE`);
|
|
296
|
+
} else {
|
|
297
|
+
plan.refusals.push(`${a.identity}: the master cannot SET ROLE to the declared owner '${a.declaredOwner}' and lacks ADMIN OPTION on it to grant itself. As a role with admin on '${a.declaredOwner}': GRANT ${a.declaredOwner} TO <master> WITH SET TRUE; then re-run.`);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
alters.push(`ALTER ${KEYWORD[a.kind]} ${a.identity} OWNER TO ${a.declaredOwner}`);
|
|
302
|
+
plan.reassigned.push({ ...a, currentOwner: p.liveOwner });
|
|
303
|
+
}
|
|
304
|
+
// Self-grants first (they make the SET conjunct true), then the reassignments — one transaction.
|
|
305
|
+
plan.statements = [...selfGrants, ...alters];
|
|
306
|
+
return plan;
|
|
307
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* owner-enactment — the ENACT half of brownfield ownership convergence (Gap 1).
|
|
3
|
+
*
|
|
4
|
+
* The reconciler (db:reconcile) runs its DDL as the least-privilege operator (`migrator`),
|
|
5
|
+
* creating each function/view AS its declared owner under `SET ROLE`. That needs two grants the
|
|
6
|
+
* operator does not hold on a fresh or brownfield database, and which db:reconcile can only
|
|
7
|
+
* REFUSE with (ownerPreflightRefusal prints the fix SQL, nothing applies it):
|
|
8
|
+
*
|
|
9
|
+
* 1. `GRANT <owner> TO <operator> WITH INHERIT FALSE, SET TRUE` — so the operator may assume
|
|
10
|
+
* the owner. INHERIT FALSE is deliberate: the operator becomes the owner only under an
|
|
11
|
+
* explicit SET ROLE, never inheriting the owner's privileges ambiently.
|
|
12
|
+
* 2. `GRANT CREATE ON SCHEMA <schema> TO <owner>` — so the CREATE, which runs AS the owner,
|
|
13
|
+
* can place the object.
|
|
14
|
+
*
|
|
15
|
+
* These are privileged, idempotent SETUP — the same category as the role chain — so they belong
|
|
16
|
+
* in db:provision, not in the least-privilege reconcile lane (which structurally cannot run them).
|
|
17
|
+
* This module is the PURE core: a probe (read) and a router that turns probe rows into the exact
|
|
18
|
+
* statements, each routed to the connection that can run it — the operator's own connection where
|
|
19
|
+
* it holds the grant right, the master where it does not, and a REFUSAL carrying runnable SQL
|
|
20
|
+
* where neither can. Nothing here opens a connection; runProvision wires the two live probes.
|
|
21
|
+
*
|
|
22
|
+
* IDEMPOTENCE is by construction, not by DO-guards: a statement is emitted ONLY when its guard is
|
|
23
|
+
* unmet (the operator cannot yet SET ROLE the owner; the owner cannot yet CREATE in the schema).
|
|
24
|
+
* A re-run, where both are satisfied, emits nothing.
|
|
25
|
+
*
|
|
26
|
+
* Owner ROLES are never invented — an absent owner is refused with `CREATE ROLE ... NOLOGIN`, the
|
|
27
|
+
* same rule the reconcile preflight holds: a security principal minted by a tool arrives with no
|
|
28
|
+
* memberships, no grants, and no review.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** PostgreSQL 16 — the first version with `GRANT ... WITH INHERIT FALSE, SET TRUE` and role `SET` membership. */
|
|
32
|
+
export const MIN_SERVER_VERSION_NUM = 160000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The version refusal, or null when the server is new enough.
|
|
36
|
+
*
|
|
37
|
+
* Below 16 the `WITH INHERIT FALSE, SET TRUE` grammar and `pg_has_role(..., 'SET')` do not exist;
|
|
38
|
+
* the nearest equivalent is a global `NOINHERIT` on the operator, which changes EVERY other
|
|
39
|
+
* inherited grant it holds — a design fork, not a syntax swap. Provision refuses rather than
|
|
40
|
+
* silently taking a different, wider action. `server_version_num` is Postgres's own integer form
|
|
41
|
+
* (e.g. 160004 for 16.4).
|
|
42
|
+
*/
|
|
43
|
+
export function pgVersionRefusal(serverVersionNum: number): string | null {
|
|
44
|
+
if (serverVersionNum >= MIN_SERVER_VERSION_NUM) return null;
|
|
45
|
+
const major = Math.floor(serverVersionNum / 10000);
|
|
46
|
+
return (
|
|
47
|
+
`owner enactment needs PostgreSQL >= 16 (this server reports ${major}). ` +
|
|
48
|
+
`The 'WITH INHERIT FALSE, SET TRUE' membership grant and role-level SET privilege are 16+; ` +
|
|
49
|
+
`below 16 the equivalent is a global NOINHERIT on the operator, which changes every other ` +
|
|
50
|
+
`inherited grant it holds — a different action provision will not take silently.`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** One declared owner and the schema(s) its objects live in — the enactment input. */
|
|
55
|
+
export interface DeclaredOwner {
|
|
56
|
+
owner: string;
|
|
57
|
+
schema: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The connection-independent facts about one (owner, schema), read once on either connection.
|
|
62
|
+
* "Can the operator already assume the owner?" and "can the owner already create in the schema?"
|
|
63
|
+
* are properties of the catalog, not of who is asking.
|
|
64
|
+
*/
|
|
65
|
+
export interface OwnerFactRow {
|
|
66
|
+
owner: string;
|
|
67
|
+
schema: string;
|
|
68
|
+
/** The owner role exists in the cluster. Absent → refuse, never invent. */
|
|
69
|
+
roleExists: boolean;
|
|
70
|
+
/** The schema exists. A CREATE grant on an absent schema cannot be written yet. */
|
|
71
|
+
schemaExists: boolean;
|
|
72
|
+
/** The operator can already `SET ROLE <owner>` — no membership grant needed. */
|
|
73
|
+
canSetRole: boolean;
|
|
74
|
+
/** The owner already holds `CREATE` on the schema — no create grant needed. */
|
|
75
|
+
ownerCanCreate: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* What ONE connection can grant, per (owner, schema) — read on that connection with CURRENT_USER.
|
|
80
|
+
* Whether this connection can `GRANT <owner> TO <operator>` (it holds ADMIN OPTION on the owner,
|
|
81
|
+
* or is a superuser) and whether it can `GRANT CREATE ON SCHEMA <schema> TO <owner>` (it owns the
|
|
82
|
+
* schema or holds CREATE WITH GRANT OPTION, or is a superuser).
|
|
83
|
+
*/
|
|
84
|
+
export interface GranterCapabilityRow {
|
|
85
|
+
owner: string;
|
|
86
|
+
schema: string;
|
|
87
|
+
canGrantMembership: boolean;
|
|
88
|
+
canGrantCreate: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The routed enactment: statements the operator runs, statements the master must run, and refusals. */
|
|
92
|
+
export interface EnactmentPlan {
|
|
93
|
+
/** Runnable on the operator's own connection. */
|
|
94
|
+
asOperator: string[];
|
|
95
|
+
/** Must run on the master connection (the operator lacks the grant right). */
|
|
96
|
+
asMaster: string[];
|
|
97
|
+
/** Neither connection can enact this — each line is a human-readable refusal carrying the fix SQL. */
|
|
98
|
+
refusals: string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const IDENT = /^[a-z_][a-z0-9_]*$/;
|
|
102
|
+
function assertIdent(name: string, what: string): void {
|
|
103
|
+
if (!IDENT.test(name)) throw new Error(`Invalid ${what}: ${JSON.stringify(name)} — must match ${IDENT}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The connection-independent probe: for each (owner, schema), does the owner exist, does the
|
|
108
|
+
* schema exist, can `operator` already SET ROLE the owner, and does the owner already hold CREATE.
|
|
109
|
+
* `has_schema_privilege` throws on an absent schema, so the schema is LEFT JOINed and the privilege
|
|
110
|
+
* is evaluated only when the schema is really there. Run once, on either connection.
|
|
111
|
+
*/
|
|
112
|
+
export function ownerFactsSql(owners: DeclaredOwner[], operator: string): string {
|
|
113
|
+
assertIdent(operator, 'operator');
|
|
114
|
+
const pairs = owners.map((o) => {
|
|
115
|
+
assertIdent(o.owner, 'owner');
|
|
116
|
+
assertIdent(o.schema, 'schema');
|
|
117
|
+
return `('${o.owner}', '${o.schema}')`;
|
|
118
|
+
});
|
|
119
|
+
return `
|
|
120
|
+
WITH declared(owner_name, schema_name) AS (VALUES ${pairs.join(', ')})
|
|
121
|
+
SELECT
|
|
122
|
+
d.owner_name AS owner,
|
|
123
|
+
d.schema_name AS schema,
|
|
124
|
+
(r.oid IS NOT NULL) AS role_exists,
|
|
125
|
+
(n.oid IS NOT NULL) AS schema_exists,
|
|
126
|
+
CASE WHEN r.oid IS NULL THEN false ELSE pg_has_role('${operator}', r.oid, 'SET') END AS can_set_role,
|
|
127
|
+
CASE WHEN r.oid IS NULL OR n.oid IS NULL THEN false
|
|
128
|
+
ELSE has_schema_privilege(r.oid, n.oid, 'CREATE') END AS owner_can_create
|
|
129
|
+
FROM declared d
|
|
130
|
+
LEFT JOIN pg_roles r ON r.rolname = d.owner_name
|
|
131
|
+
LEFT JOIN pg_namespace n ON n.nspname = d.schema_name
|
|
132
|
+
ORDER BY d.owner_name, d.schema_name
|
|
133
|
+
`.trim();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The connection-DEPENDENT probe: run on a connection to learn what CURRENT_USER can grant.
|
|
138
|
+
* Can it `GRANT <owner> TO <operator>` — it holds ADMIN OPTION on the owner (pg_auth_members), or
|
|
139
|
+
* is a superuser (RDS: a member of rds_superuser). Can it `GRANT CREATE ON SCHEMA <schema>` — it
|
|
140
|
+
* owns the schema, holds CREATE WITH GRANT OPTION, or is a superuser. Run on the operator's
|
|
141
|
+
* connection and, when present, on the master's; the router merges the two.
|
|
142
|
+
*/
|
|
143
|
+
export function granterCapabilitySql(owners: DeclaredOwner[]): string {
|
|
144
|
+
const pairs = owners.map((o) => {
|
|
145
|
+
assertIdent(o.owner, 'owner');
|
|
146
|
+
assertIdent(o.schema, 'schema');
|
|
147
|
+
return `('${o.owner}', '${o.schema}')`;
|
|
148
|
+
});
|
|
149
|
+
return `
|
|
150
|
+
WITH declared(owner_name, schema_name) AS (VALUES ${pairs.join(', ')}),
|
|
151
|
+
me AS (
|
|
152
|
+
SELECT b.oid, (b.rolsuper OR EXISTS (
|
|
153
|
+
SELECT 1 FROM pg_auth_members m JOIN pg_roles g ON g.oid = m.roleid
|
|
154
|
+
WHERE m.member = b.oid AND g.rolname = 'rds_superuser'
|
|
155
|
+
)) AS is_super
|
|
156
|
+
FROM pg_roles b WHERE b.rolname = CURRENT_USER
|
|
157
|
+
)
|
|
158
|
+
SELECT
|
|
159
|
+
d.owner_name AS owner,
|
|
160
|
+
d.schema_name AS schema,
|
|
161
|
+
(me.is_super OR EXISTS (
|
|
162
|
+
SELECT 1 FROM pg_auth_members m
|
|
163
|
+
WHERE m.roleid = r.oid AND m.member = me.oid AND m.admin_option
|
|
164
|
+
)) AS can_grant_membership,
|
|
165
|
+
(me.is_super OR n.nspowner = me.oid
|
|
166
|
+
OR has_schema_privilege(me.oid, n.oid, 'CREATE WITH GRANT OPTION')) AS can_grant_create
|
|
167
|
+
FROM declared d
|
|
168
|
+
CROSS JOIN me
|
|
169
|
+
LEFT JOIN pg_roles r ON r.rolname = d.owner_name
|
|
170
|
+
LEFT JOIN pg_namespace n ON n.nspname = d.schema_name
|
|
171
|
+
ORDER BY d.owner_name, d.schema_name
|
|
172
|
+
`.trim();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const keyOf = (owner: string, schema: string) => `${owner}${schema}`;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Route the two grants each (owner, schema) may need onto the connection that can run them.
|
|
179
|
+
*
|
|
180
|
+
* `operator` is the least-privilege role (`migrator`). `masterCaps` is null when no master
|
|
181
|
+
* connection is available — then anything the operator cannot do is a refusal, never an
|
|
182
|
+
* unverified attempt on a connection that may not exist. Fable's rule: precise pre-flight
|
|
183
|
+
* refusal beats a mid-batch failure, and the master's capability is PROBED, never assumed.
|
|
184
|
+
*/
|
|
185
|
+
export function planOwnerEnactment(
|
|
186
|
+
operator: string,
|
|
187
|
+
facts: OwnerFactRow[],
|
|
188
|
+
operatorCaps: GranterCapabilityRow[],
|
|
189
|
+
masterCaps: GranterCapabilityRow[] | null,
|
|
190
|
+
): EnactmentPlan {
|
|
191
|
+
const opCap = new Map(operatorCaps.map((c) => [keyOf(c.owner, c.schema), c]));
|
|
192
|
+
const mCap = masterCaps ? new Map(masterCaps.map((c) => [keyOf(c.owner, c.schema), c])) : null;
|
|
193
|
+
const plan: EnactmentPlan = { asOperator: [], asMaster: [], refusals: [] };
|
|
194
|
+
|
|
195
|
+
for (const f of facts) {
|
|
196
|
+
if (!f.roleExists) {
|
|
197
|
+
plan.refusals.push(
|
|
198
|
+
`owner role '${f.owner}' does not exist — provision never invents a security principal. ` +
|
|
199
|
+
`Create it, then re-run: CREATE ROLE ${f.owner} NOLOGIN;`,
|
|
200
|
+
);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const op = opCap.get(keyOf(f.owner, f.schema));
|
|
204
|
+
const master = mCap?.get(keyOf(f.owner, f.schema)) ?? null;
|
|
205
|
+
|
|
206
|
+
// 1. Membership: the operator must be able to SET ROLE the owner.
|
|
207
|
+
if (!f.canSetRole) {
|
|
208
|
+
const stmt = `GRANT ${f.owner} TO ${operator} WITH INHERIT FALSE, SET TRUE`;
|
|
209
|
+
if (op?.canGrantMembership) plan.asOperator.push(stmt);
|
|
210
|
+
else if (master?.canGrantMembership) plan.asMaster.push(stmt);
|
|
211
|
+
else plan.refusals.push(
|
|
212
|
+
`cannot GRANT ${f.owner} TO ${operator}: neither the operator nor the master holds ADMIN OPTION on '${f.owner}'. ` +
|
|
213
|
+
`Run as a role that does: ${stmt};`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 2. Create: the owner must be able to CREATE in the schema the object lives in.
|
|
218
|
+
if (f.schemaExists && !f.ownerCanCreate) {
|
|
219
|
+
const stmt = `GRANT CREATE ON SCHEMA ${f.schema} TO ${f.owner}`;
|
|
220
|
+
if (op?.canGrantCreate) plan.asOperator.push(stmt);
|
|
221
|
+
else if (master?.canGrantCreate) plan.asMaster.push(stmt);
|
|
222
|
+
else plan.refusals.push(
|
|
223
|
+
`cannot GRANT CREATE ON SCHEMA ${f.schema} TO ${f.owner}: neither connection owns the schema or holds CREATE WITH GRANT OPTION. ` +
|
|
224
|
+
`Run as the schema owner: ${stmt};`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
// An absent schema is not enacted here — it is created by the reconcile builder, whose own
|
|
228
|
+
// preflight then names the owner's missing CREATE. Enactment acts only on what exists.
|
|
229
|
+
}
|
|
230
|
+
return plan;
|
|
231
|
+
}
|
package/src/plugin.ts
CHANGED
|
@@ -842,6 +842,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
842
842
|
if (db?.host) connection = { host: db.host, port: db.port, database: db.database };
|
|
843
843
|
}
|
|
844
844
|
|
|
845
|
+
// Credentials ONLY. Ownership convergence is a SEPARATE action (db:converge-ownership) the CLI
|
|
846
|
+
// runs AFTER it has written the credential to the secret store — so a long/killable ownership
|
|
847
|
+
// step can never sit between the password swap and the secret write (the lockout, see provision.ts).
|
|
845
848
|
return await runProvision(
|
|
846
849
|
(payload || {}) as Parameters<typeof runProvision>[0],
|
|
847
850
|
{
|
|
@@ -872,6 +875,47 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
872
875
|
);
|
|
873
876
|
};
|
|
874
877
|
|
|
878
|
+
// --- db:converge-ownership — ENACT + ADOPT, AFTER the CLI has written the secret ---
|
|
879
|
+
// The second half of the provisioning flow, split out so it runs strictly after the credential
|
|
880
|
+
// is safely in the secret store: credentials (db:provision) → secret write (CLI) → this. A hard
|
|
881
|
+
// kill here strands nothing — the worst case is an un-converged owner db:reconcile refuses on.
|
|
882
|
+
actions['db:converge-ownership'] = async (payload, _ctx) => {
|
|
883
|
+
const { sql } = await import('drizzle-orm');
|
|
884
|
+
const { convergeOwnership } = await import('./provision');
|
|
885
|
+
const { getMasterDatabaseUrl } = await import('./db');
|
|
886
|
+
|
|
887
|
+
const queryRows = async (statement: string): Promise<any[]> => {
|
|
888
|
+
const res: any = await opsDb.execute(sql.raw(statement));
|
|
889
|
+
if (Array.isArray(res)) return res;
|
|
890
|
+
if (Array.isArray(res?.rows)) return res.rows;
|
|
891
|
+
return [];
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
// The MASTER connection (linked RDS master), when linked — ENACT routes to it the grants the
|
|
895
|
+
// operator cannot make, and ADOPT requires it outright. Absent on a BYO-database app: master
|
|
896
|
+
// stays null and ADOPT refuses with the fix SQL. Opened for this invoke, closed in the finally.
|
|
897
|
+
const masterUrl = getMasterDatabaseUrl();
|
|
898
|
+
let masterClient: any = null;
|
|
899
|
+
let master: { execute: (s: string) => Promise<unknown>; query: (s: string) => Promise<any[]> } | null = null;
|
|
900
|
+
if (masterUrl) {
|
|
901
|
+
const { default: postgres } = await import('postgres');
|
|
902
|
+
masterClient = postgres(masterUrl, { max: 1, connect_timeout: 10, ssl: 'prefer', onnotice: () => {} });
|
|
903
|
+
master = {
|
|
904
|
+
execute: async (s: string) => { await masterClient.unsafe(s); },
|
|
905
|
+
query: async (s: string) => Array.from(await masterClient.unsafe(s)) as any[],
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
try {
|
|
910
|
+
return await convergeOwnership(
|
|
911
|
+
(payload || {}) as Parameters<typeof convergeOwnership>[0],
|
|
912
|
+
{ execute: async (statement) => opsDb.execute(sql.raw(statement)), query: queryRows, master },
|
|
913
|
+
);
|
|
914
|
+
} finally {
|
|
915
|
+
if (masterClient) await masterClient.end({ timeout: 5 });
|
|
916
|
+
}
|
|
917
|
+
};
|
|
918
|
+
|
|
875
919
|
|
|
876
920
|
// --- db:rotate — A/B role detection, provisioning, retirement, draining ---
|
|
877
921
|
// Runs inside the VPC on the operator connection. The CLI orchestrates the
|
package/src/provision.ts
CHANGED
|
@@ -21,6 +21,25 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { generateRoleChainSQL, generateAdminRoleSQL, generateSearchPathSQL, generateRotationPairSQL } from './role-chain';
|
|
24
|
+
import {
|
|
25
|
+
pgVersionRefusal,
|
|
26
|
+
ownerFactsSql,
|
|
27
|
+
granterCapabilitySql,
|
|
28
|
+
planOwnerEnactment,
|
|
29
|
+
type DeclaredOwner,
|
|
30
|
+
type OwnerFactRow,
|
|
31
|
+
type GranterCapabilityRow,
|
|
32
|
+
} from './owner-enactment';
|
|
33
|
+
import {
|
|
34
|
+
adopteeProbeSql,
|
|
35
|
+
planAdoptionReassignment,
|
|
36
|
+
auditAdoption,
|
|
37
|
+
requiresConfirm,
|
|
38
|
+
type Adoptee,
|
|
39
|
+
type AdopteeProbe,
|
|
40
|
+
type RoleAttrs,
|
|
41
|
+
type AdoptionFinding,
|
|
42
|
+
} from './owner-adoption';
|
|
24
43
|
|
|
25
44
|
export interface ProvisionPayload {
|
|
26
45
|
/** Password for the least-privilege API login. Alphanumeric only. */
|
|
@@ -30,6 +49,27 @@ export interface ProvisionPayload {
|
|
|
30
49
|
loginRole?: string;
|
|
31
50
|
adminRole?: string;
|
|
32
51
|
appRoles?: string[];
|
|
52
|
+
/**
|
|
53
|
+
* Declared owners to ENACT (brownfield ownership convergence, Gap 1). Each (owner, schema) the
|
|
54
|
+
* Models declare for a function/view/matview. Provision grants the operator membership so it can
|
|
55
|
+
* `SET ROLE` the owner, and the owner CREATE on the schema — the two grants db:reconcile needs
|
|
56
|
+
* but (as the least-privilege operator) can only refuse with. Absent/empty = no enactment
|
|
57
|
+
* (byte-identical to pre-feature behavior). Derived from the Models barrel by the CLI.
|
|
58
|
+
*/
|
|
59
|
+
declaredOwners?: DeclaredOwner[];
|
|
60
|
+
/**
|
|
61
|
+
* Declared objects to ADOPT (brownfield ownership convergence, Gap 2). Each function/view/matview
|
|
62
|
+
* the Models declare, with its declared owner. Provision reassigns (on the MASTER, in one
|
|
63
|
+
* transaction) any that exist and are mis-owned. Reassigning a SECDEF function/view/matview
|
|
64
|
+
* changes its runs-as identity at CALL time, so those require `confirm`. Absent/empty = no adoption.
|
|
65
|
+
*/
|
|
66
|
+
declaredObjects?: Adoptee[];
|
|
67
|
+
/**
|
|
68
|
+
* Explicit confirmation for a runtime-changing ADOPT. Without it, provision REFUSES (and reports
|
|
69
|
+
* the manifest) when any object it would reassign is a SECDEF function, a view, or a matview. The
|
|
70
|
+
* CLI sets this from `--confirm`.
|
|
71
|
+
*/
|
|
72
|
+
confirm?: boolean;
|
|
33
73
|
/**
|
|
34
74
|
* The declared schema set (ordered, deduped, ending in 'public' — declaredSearchPath). When
|
|
35
75
|
* present, provision sets it as the ROLE default (ALTER ROLE … SET search_path) on both login
|
|
@@ -41,6 +81,15 @@ export interface ProvisionPayload {
|
|
|
41
81
|
rotationPairs?: boolean;
|
|
42
82
|
}
|
|
43
83
|
|
|
84
|
+
/**
|
|
85
|
+
* A privileged connection that can both write and READ rows — used for the enactment probes.
|
|
86
|
+
* The operator's own connection has this shape; the master (linked RDS) has it when present.
|
|
87
|
+
*/
|
|
88
|
+
export interface ProvisionConnection {
|
|
89
|
+
execute: (sql: string) => Promise<unknown>;
|
|
90
|
+
query: (sql: string) => Promise<any[]>;
|
|
91
|
+
}
|
|
92
|
+
|
|
44
93
|
export interface ProvisionDeps {
|
|
45
94
|
/** Run one SQL string on the (privileged) provisioning connection. */
|
|
46
95
|
execute: (sql: string) => Promise<unknown>;
|
|
@@ -48,6 +97,19 @@ export interface ProvisionDeps {
|
|
|
48
97
|
connection?: { host: string; port?: number | string; database: string } | null;
|
|
49
98
|
/** Real login attempt as the admin role — resolves on success, throws on failure. */
|
|
50
99
|
connectProbe?: (url: string) => Promise<void>;
|
|
100
|
+
/**
|
|
101
|
+
* Read rows on the operator's own (privileged) connection — for the enactment probes and the
|
|
102
|
+
* server-version guard. Absent → enactment is skipped even when declaredOwners is present (an
|
|
103
|
+
* older caller that cannot read; the reconcile preflight still refuses with the fix SQL).
|
|
104
|
+
*/
|
|
105
|
+
query?: (sql: string) => Promise<any[]>;
|
|
106
|
+
/**
|
|
107
|
+
* The MASTER connection (the linked RDS master), when one is available. ENACT routes to it the
|
|
108
|
+
* grants the operator cannot make; ADOPT (Gap 2) requires it outright. Absent on a BYO-database
|
|
109
|
+
* app (no linked component) → enactment that needs the master is REFUSED with the fix SQL,
|
|
110
|
+
* never attempted on a connection that does not exist.
|
|
111
|
+
*/
|
|
112
|
+
master?: ProvisionConnection | null;
|
|
51
113
|
/**
|
|
52
114
|
* Does this role ALREADY exist with LOGIN? When it does, provision is ADDITIVE-ONLY:
|
|
53
115
|
* it preserves the live password instead of resetting it. A plain `db:provision` used to
|
|
@@ -70,6 +132,27 @@ export interface ProvisionResult {
|
|
|
70
132
|
/** true = login proven; null = no way to probe (unverified); absent = no admin requested. */
|
|
71
133
|
adminVerified?: boolean | null;
|
|
72
134
|
rotationPairsCreated?: boolean;
|
|
135
|
+
/**
|
|
136
|
+
* The result of ENACT (declared-owner convergence, Gap 1). Absent when no owners were declared.
|
|
137
|
+
* `enacted` counts grants actually run; `refusals` are grants neither the operator nor the master
|
|
138
|
+
* could make, each carrying the fix SQL. Refusals do NOT abort credential provisioning (that is
|
|
139
|
+
* the critical, atomic path) — they are reported LOUDLY for the CLI to surface and exit non-zero.
|
|
140
|
+
* The database is not left worse: an un-enacted owner is exactly the state db:reconcile refuses on.
|
|
141
|
+
*/
|
|
142
|
+
ownerEnactment?: { enacted: number; refusals: string[] };
|
|
143
|
+
/**
|
|
144
|
+
* The result of ADOPT (declared-object reassignment, Gap 2). Absent when no objects were declared.
|
|
145
|
+
* `reassigned` counts objects moved; `manifest` describes each (identity, kind, secdef, owner
|
|
146
|
+
* change) for the CLI to print; `findings` are the pre-adopt semantic audit; `refusals` block the
|
|
147
|
+
* whole phase (a pre-flight obstacle, a missing master, or a required --confirm that was absent).
|
|
148
|
+
* Like ENACT, refusals never abort credential provisioning and never throw.
|
|
149
|
+
*/
|
|
150
|
+
ownerAdoption?: {
|
|
151
|
+
reassigned: number;
|
|
152
|
+
manifest: Array<{ identity: string; kind: string; secdef: boolean; from: string; to: string }>;
|
|
153
|
+
findings: AdoptionFinding[];
|
|
154
|
+
refusals: string[];
|
|
155
|
+
};
|
|
73
156
|
error?: string;
|
|
74
157
|
}
|
|
75
158
|
|
|
@@ -207,5 +290,193 @@ export async function runProvision(
|
|
|
207
290
|
result.passwordSet = true;
|
|
208
291
|
}
|
|
209
292
|
|
|
293
|
+
// Ownership convergence (ENACT + ADOPT) is DELIBERATELY NOT here. It is the one part of the
|
|
294
|
+
// provisioning flow that can wait on a lock (ADOPT's transaction) or outlive the Lambda clock,
|
|
295
|
+
// and it sits AFTER the credential swap — so running it inside runProvision would put a
|
|
296
|
+
// long/killable step between the autocommitted `ALTER ROLE … PASSWORD` and the caller's
|
|
297
|
+
// secret-store write. A hard kill there (900s Lambda timeout, SIGKILL) strands the fresh operator
|
|
298
|
+
// password live in the database but never saved: the exact lockout the ordering above exists to
|
|
299
|
+
// prevent, and one no try/catch can catch. So the caller runs `convergeOwnership` as a SEPARATE
|
|
300
|
+
// step, AFTER it has written the secret — credentials → secret → ENACT → ADOPT.
|
|
210
301
|
return result;
|
|
211
302
|
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Converge declared ownership — ENACT (Gap 1) then ADOPT (Gap 2) — as a step SEPARATE from and
|
|
306
|
+
* AFTER credential provisioning + the secret-store write (see runProvision's closing note). By the
|
|
307
|
+
* time this runs the operator credential is already saved, so a throw or a hard kill here can strand
|
|
308
|
+
* nothing: the worst case is an un-converged owner, exactly the state db:reconcile already refuses on.
|
|
309
|
+
*
|
|
310
|
+
* Both phases are non-fatal: a thrown execute is folded into `refusals`, never propagated. Returns
|
|
311
|
+
* the same `ownerEnactment` / `ownerAdoption` shapes runProvision's result used to carry. Skipped
|
|
312
|
+
* (empty result) when nothing is declared or the caller cannot read (no `query` dep — an older CLI).
|
|
313
|
+
*/
|
|
314
|
+
export async function convergeOwnership(
|
|
315
|
+
payload: Pick<ProvisionPayload, 'declaredOwners' | 'declaredObjects' | 'confirm' | 'adminRole'>,
|
|
316
|
+
deps: ProvisionDeps,
|
|
317
|
+
): Promise<Pick<ProvisionResult, 'ownerEnactment' | 'ownerAdoption'>> {
|
|
318
|
+
const out: Pick<ProvisionResult, 'ownerEnactment' | 'ownerAdoption'> = {};
|
|
319
|
+
if (!deps.query) return out;
|
|
320
|
+
const operator = payload.adminRole ?? 'migrator';
|
|
321
|
+
|
|
322
|
+
if (payload.declaredOwners?.length) {
|
|
323
|
+
try {
|
|
324
|
+
out.ownerEnactment = await enactOwners(payload.declaredOwners, operator, deps);
|
|
325
|
+
} catch (err: any) {
|
|
326
|
+
out.ownerEnactment = { enacted: 0, refusals: [`owner enactment failed (credentials are already saved; nothing is stranded): ${err?.message ?? String(err)}`] };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (payload.declaredObjects?.length) {
|
|
331
|
+
try {
|
|
332
|
+
out.ownerAdoption = await adoptOwners(payload.declaredObjects, deps, !!payload.confirm);
|
|
333
|
+
} catch (err: any) {
|
|
334
|
+
out.ownerAdoption = { reassigned: 0, manifest: [], findings: [], refusals: [`owner adoption failed (the transaction rolled back; credentials are untouched): ${err?.message ?? String(err)}`] };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
|
|
341
|
+
|
|
342
|
+
function toFacts(rows: any[]): OwnerFactRow[] {
|
|
343
|
+
return rows.map((r) => ({
|
|
344
|
+
owner: String(r.owner),
|
|
345
|
+
schema: String(r.schema),
|
|
346
|
+
roleExists: truthy(r.role_exists),
|
|
347
|
+
schemaExists: truthy(r.schema_exists),
|
|
348
|
+
canSetRole: truthy(r.can_set_role),
|
|
349
|
+
ownerCanCreate: truthy(r.owner_can_create),
|
|
350
|
+
}));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function toCaps(rows: any[]): GranterCapabilityRow[] {
|
|
354
|
+
return rows.map((r) => ({
|
|
355
|
+
owner: String(r.owner),
|
|
356
|
+
schema: String(r.schema),
|
|
357
|
+
canGrantMembership: truthy(r.can_grant_membership),
|
|
358
|
+
canGrantCreate: truthy(r.can_grant_create),
|
|
359
|
+
}));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Probe (operator + master), route, and apply the declared-owner grants. The PG16 guard runs
|
|
364
|
+
* first: below 16 the grammar does not exist and provision refuses rather than take a wider action.
|
|
365
|
+
* Master-routed statements need a live master; without one they were already turned into refusals
|
|
366
|
+
* by the router (masterCaps=null), so nothing here attempts a connection that is not there.
|
|
367
|
+
*/
|
|
368
|
+
async function enactOwners(
|
|
369
|
+
owners: DeclaredOwner[],
|
|
370
|
+
operator: string,
|
|
371
|
+
deps: ProvisionDeps,
|
|
372
|
+
): Promise<{ enacted: number; refusals: string[] }> {
|
|
373
|
+
const query = deps.query!;
|
|
374
|
+
const [vRow] = await query(`SELECT current_setting('server_version_num')::int AS v`);
|
|
375
|
+
const vRefusal = pgVersionRefusal(Number(vRow?.v ?? 0));
|
|
376
|
+
if (vRefusal) return { enacted: 0, refusals: [vRefusal] };
|
|
377
|
+
|
|
378
|
+
const facts = toFacts(await query(ownerFactsSql(owners, operator)));
|
|
379
|
+
const operatorCaps = toCaps(await query(granterCapabilitySql(owners)));
|
|
380
|
+
const masterCaps = deps.master ? toCaps(await deps.master.query(granterCapabilitySql(owners))) : null;
|
|
381
|
+
|
|
382
|
+
const plan = planOwnerEnactment(operator, facts, operatorCaps, masterCaps);
|
|
383
|
+
for (const stmt of plan.asOperator) await deps.execute(`${stmt};`);
|
|
384
|
+
for (const stmt of plan.asMaster) await deps.master!.execute(`${stmt};`);
|
|
385
|
+
return { enacted: plan.asOperator.length + plan.asMaster.length, refusals: plan.refusals };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function toProbes(rows: any[]): AdopteeProbe[] {
|
|
389
|
+
return rows.map((r) => ({
|
|
390
|
+
identity: String(r.identity),
|
|
391
|
+
exists: truthy(r.exists),
|
|
392
|
+
kindMatches: truthy(r.kind_matches),
|
|
393
|
+
liveSecdef: truthy(r.live_secdef),
|
|
394
|
+
liveOwner: String(r.live_owner ?? ''),
|
|
395
|
+
isExtensionMember: truthy(r.is_extension_member),
|
|
396
|
+
declaredOwnerExists: truthy(r.declared_owner_exists),
|
|
397
|
+
masterPrivsOfCurrent: truthy(r.master_privs_of_current),
|
|
398
|
+
masterCanSetDeclared: truthy(r.master_can_set_declared),
|
|
399
|
+
masterAdminOfDeclared: truthy(r.master_admin_of_declared),
|
|
400
|
+
}));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const EMPTY_ADOPTION = { reassigned: 0, manifest: [], findings: [], refusals: [] as string[] };
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Reassign declared objects to their declared owners — the ADOPT phase (Gap 2), master-only and
|
|
407
|
+
* confirm-gated. The reassignment is one transaction with a per-statement lock_timeout, so a stall
|
|
408
|
+
* behind a long reader fails fast and a mid-batch error rolls the whole phase back — never a
|
|
409
|
+
* half-adopted schema (the residue db:reconcile would then choke on).
|
|
410
|
+
*/
|
|
411
|
+
async function adoptOwners(
|
|
412
|
+
adoptees: Adoptee[],
|
|
413
|
+
deps: ProvisionDeps,
|
|
414
|
+
confirm: boolean,
|
|
415
|
+
): Promise<NonNullable<ProvisionResult['ownerAdoption']>> {
|
|
416
|
+
const query = deps.query!;
|
|
417
|
+
const [vRow] = await query(`SELECT current_setting('server_version_num')::int AS v`);
|
|
418
|
+
const vRefusal = pgVersionRefusal(Number(vRow?.v ?? 0));
|
|
419
|
+
if (vRefusal) return { ...EMPTY_ADOPTION, refusals: [vRefusal] };
|
|
420
|
+
|
|
421
|
+
// ADOPT cannot run as the least-privilege operator (it would need INHERIT membership in the
|
|
422
|
+
// current owner `postgres`, which is the master). No master → a precise refusal, never an attempt.
|
|
423
|
+
if (!deps.master) {
|
|
424
|
+
return { ...EMPTY_ADOPTION, refusals: [
|
|
425
|
+
'ADOPT requires the master connection — the least-privilege operator cannot reassign a ' +
|
|
426
|
+
'postgres-owned object. Link an sst.aws.Postgres component (the master fallback), or run the ' +
|
|
427
|
+
'ALTER … OWNER TO statements as the master by hand.',
|
|
428
|
+
] };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const probes = toProbes(await deps.master.query(adopteeProbeSql(adoptees)));
|
|
432
|
+
const plan = planAdoptionReassignment(adoptees, probes);
|
|
433
|
+
if (plan.refusals.length) return { ...EMPTY_ADOPTION, refusals: plan.refusals };
|
|
434
|
+
if (!plan.statements.length) return { ...EMPTY_ADOPTION };
|
|
435
|
+
|
|
436
|
+
// The semantic audit and the confirm gate — computed from what will ACTUALLY reassign, using LIVE
|
|
437
|
+
// facts (the declared owner's live attributes; the object's live SECDEF flag), never the declared
|
|
438
|
+
// guess (Fable BLOCKER C). `probeById` recovers each reassignee's live SECDEF.
|
|
439
|
+
const probeById = new Map(probes.map((p) => [p.identity, p]));
|
|
440
|
+
const owners = new Set<string>();
|
|
441
|
+
for (const a of plan.reassigned) { owners.add(a.currentOwner); owners.add(a.declaredOwner); }
|
|
442
|
+
const attrs = await roleAttrs(deps.master, [...owners]);
|
|
443
|
+
// Audit with the EFFECTIVE runs-as: SECDEF if declared OR live (a live-SECDEF/declared-INVOKER fn
|
|
444
|
+
// still runs as its owner until reconcile replaces the body).
|
|
445
|
+
const findings = auditAdoption(
|
|
446
|
+
plan.reassigned.map((a) => ({ ...a, secdef: a.secdef || !!probeById.get(a.identity)?.liveSecdef })),
|
|
447
|
+
attrs,
|
|
448
|
+
);
|
|
449
|
+
const manifest = plan.reassigned.map((a) => ({
|
|
450
|
+
identity: a.identity, kind: a.kind, secdef: a.secdef || !!probeById.get(a.identity)?.liveSecdef, from: a.currentOwner, to: a.declaredOwner,
|
|
451
|
+
}));
|
|
452
|
+
|
|
453
|
+
const reassignments = plan.reassigned.map((a) => ({
|
|
454
|
+
kind: a.kind, declaredSecdef: a.secdef, liveSecdef: !!probeById.get(a.identity)?.liveSecdef, declaredOwner: a.declaredOwner,
|
|
455
|
+
}));
|
|
456
|
+
if (requiresConfirm(reassignments, attrs) && !confirm) {
|
|
457
|
+
const n = reassignments.filter((r) => requiresConfirm([r], attrs)).length;
|
|
458
|
+
return { reassigned: 0, manifest, findings, refusals: [
|
|
459
|
+
`ADOPT needs --confirm: ${n} object(s) change their runs-as identity at call time or hand ` +
|
|
460
|
+
`ownership to a powerful role (SECDEF functions / views / matviews / superuser-or-BYPASSRLS owners). ` +
|
|
461
|
+
`Review the manifest, then re-run with --confirm.`,
|
|
462
|
+
] };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// One transaction: per-statement lock_timeout, all-or-nothing. `statements` may include SET
|
|
466
|
+
// self-grants ahead of the ALTERs, so the count of reassignments is plan.reassigned, not statements.
|
|
467
|
+
const body = plan.statements.map((s) => `${s};`).join('\n');
|
|
468
|
+
await deps.master.execute(`BEGIN;\nSET LOCAL lock_timeout = '5000ms';\n${body}\nCOMMIT;`);
|
|
469
|
+
return { reassigned: plan.reassigned.length, manifest, findings, refusals: [] };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Read superuser / bypassrls for a set of roles, on the master. */
|
|
473
|
+
async function roleAttrs(master: ProvisionConnection, roles: string[]): Promise<Record<string, RoleAttrs>> {
|
|
474
|
+
if (!roles.length) return {};
|
|
475
|
+
const list = roles.map((r) => `'${r.replace(/'/g, "''")}'`).join(', ');
|
|
476
|
+
const rows = await master.query(
|
|
477
|
+
`SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname IN (${list})`,
|
|
478
|
+
);
|
|
479
|
+
const out: Record<string, RoleAttrs> = {};
|
|
480
|
+
for (const r of rows) out[String(r.rolname)] = { superuser: truthy(r.rolsuper), bypassRls: truthy(r.rolbypassrls) };
|
|
481
|
+
return out;
|
|
482
|
+
}
|