@everystack/cli 0.2.39 → 0.3.0

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.
@@ -0,0 +1,248 @@
1
+ /**
2
+ * The human-readable view of the Authorization Contract.
3
+ *
4
+ * The JSON contract is machine-grade — correct for diff/test/probe, but a CTO cannot
5
+ * scan `(author_id = (((current_setting('request.jwt.claims'::text, true))::jsonb ->>
6
+ * 'sub'::text))::uuid)` and confirm it means "owns the row". This renders the same
7
+ * contract as a role x action matrix with the predicates translated to plain English,
8
+ * so a human can read it and confirm or deny the rules match intent.
9
+ *
10
+ * `humanizePredicate` recognises the handful of shapes that make up ~all real policies
11
+ * (owner check, soft-delete guard, boolean, simple comparisons) — the same
12
+ * classification the v3 reverse-compiler (db:pull -> can()) needs, so it is not
13
+ * throwaway. Anything it does not recognise is shown verbatim, never hidden.
14
+ */
15
+
16
+ import type { AuthzContract, TableContract, PolicyContract } from './authz-contract.js';
17
+ import { hasPrivilege } from './authz-redteam.js';
18
+
19
+ /** True when the expression resolves to the signed-in user's id (any of the forms). */
20
+ function isCurrentUserExpr(s: string): boolean {
21
+ return /current_setting\([^)]*jwt\.claims/i.test(s)
22
+ || /auth\.uid\(\)/i.test(s)
23
+ || /auth\.jwt\(\)\s*->>\s*'sub'/i.test(s);
24
+ }
25
+
26
+ /** Strip one layer of redundant outer parentheses, repeatedly. */
27
+ function unwrap(s: string): string {
28
+ let t = s.trim();
29
+ while (t.startsWith('(') && t.endsWith(')') && balanced(t.slice(1, -1))) {
30
+ t = t.slice(1, -1).trim();
31
+ }
32
+ return t;
33
+ }
34
+
35
+ /** True when every '(' in s closes before the end — i.e. the outer parens were redundant. */
36
+ function balanced(s: string): boolean {
37
+ let depth = 0;
38
+ for (const ch of s) {
39
+ if (ch === '(') depth++;
40
+ else if (ch === ')') { if (--depth < 0) return false; }
41
+ }
42
+ return depth === 0;
43
+ }
44
+
45
+ /** Split on a top-level keyword (OR / AND), respecting parentheses. */
46
+ function splitTop(s: string, keyword: 'OR' | 'AND'): string[] {
47
+ const parts: string[] = [];
48
+ let depth = 0;
49
+ let last = 0;
50
+ const re = new RegExp(`\\b${keyword}\\b`, 'gi');
51
+ for (let i = 0; i < s.length; i++) {
52
+ if (s[i] === '(') depth++;
53
+ else if (s[i] === ')') depth--;
54
+ else if (depth === 0) {
55
+ re.lastIndex = i;
56
+ const m = re.exec(s);
57
+ if (m && m.index === i) {
58
+ parts.push(s.slice(last, i));
59
+ i += m[0].length - 1;
60
+ last = i + 1;
61
+ }
62
+ }
63
+ }
64
+ parts.push(s.slice(last));
65
+ return parts.map((p) => p.trim()).filter(Boolean);
66
+ }
67
+
68
+ /** One comparison/atom -> a phrase. Falls back to the raw SQL in backticks. */
69
+ function ownerOf(col: string): string {
70
+ return `owner (\`${col.replace(/"/g, '')}\`)`;
71
+ }
72
+
73
+ function humanizeAtom(atom: string): string {
74
+ const a = unwrap(atom);
75
+ if (/^true$/i.test(a)) return 'any row';
76
+ if (/^false$/i.test(a)) return 'no row';
77
+
78
+ // <col> IN ( SELECT ... FROM <parent> WHERE ... <current user> ... ) — parent-visibility
79
+ // inheritance: you may touch this row when you own the parent it links to.
80
+ let m = a.match(/^([\w".]+)\s+IN\s*\(\s*SELECT[\s\S]*?\bFROM\s+([\w".]+)/i);
81
+ if (m && isCurrentUserExpr(a)) {
82
+ return `owner of linked \`${m[2].replace(/"/g, '')}\` (via \`${m[1].replace(/"/g, '')}\`)`;
83
+ }
84
+
85
+ // <col> = <current-user-id-expr> (in either order) -> ownership. Anchored on the
86
+ // column and the start of the identity expression, so the trailing cast/paren noise
87
+ // (`::uuid`, `))`) never matters.
88
+ if (isCurrentUserExpr(a)) {
89
+ m = a.match(/^\(*\s*([\w".]+)\s*=\s*\(*\s*(?:current_setting|auth\.)/i);
90
+ if (m) return ownerOf(m[1]);
91
+ m = a.match(/=\s*([\w".]+)\s*\)*$/);
92
+ if (m) return ownerOf(m[1]);
93
+ // A subquery / EXISTS keyed on the current user that we can't reduce cleanly — be
94
+ // honest that it depends on the user via related data, and point at the JSON.
95
+ if (/\bselect\b|\bexists\b/i.test(a)) return 'depends on the signed-in user (via a subquery — see JSON)';
96
+ return 'the current user';
97
+ }
98
+
99
+ // a bare boolean column used as a predicate (e.g. `active`) means "<col> is true"
100
+ if (/^[\w".]+$/.test(a)) return `\`${a.replace(/"/g, '')}\` is true`;
101
+
102
+ // soft-delete / null guards
103
+ m = a.match(/^([\w".]+)\s+IS\s+NULL$/i);
104
+ if (m) return /delet/i.test(m[1]) ? 'not deleted' : `\`${m[1].replace(/"/g, '')}\` is empty`;
105
+ m = a.match(/^([\w".]+)\s+IS\s+NOT\s+NULL$/i);
106
+ if (m) return `\`${m[1].replace(/"/g, '')}\` is set`;
107
+
108
+ // simple equality / membership against a literal
109
+ m = a.match(/^([\w".]+)\s*=\s*('[^']*'|\w+)$/i);
110
+ if (m) return `\`${m[1].replace(/"/g, '')}\` = ${m[2]}`;
111
+ m = a.match(/^([\w".]+)\s+IN\s+(\(.+\))$/i);
112
+ if (m) return `\`${m[1].replace(/"/g, '')}\` in ${m[2]}`;
113
+
114
+ return `\`${a}\``;
115
+ }
116
+
117
+ /**
118
+ * Translate an RLS predicate to a plain-English phrase. null/empty -> "any row".
119
+ * Handles OR / AND combinations of the recognised atoms; unknown atoms are shown raw.
120
+ */
121
+ export function humanizePredicate(sql: string | null): string {
122
+ if (!sql || !sql.trim()) return 'any row';
123
+ const expr = unwrap(sql);
124
+
125
+ const ors = splitTop(expr, 'OR');
126
+ if (ors.length > 1) return ors.map(renderConj).join(' OR ');
127
+ return renderConj(expr);
128
+ }
129
+
130
+ function renderConj(s: string): string {
131
+ const ands = splitTop(s, 'AND');
132
+ if (ands.length > 1) return ands.map(humanizeAtom).join(' AND ');
133
+ return humanizeAtom(s);
134
+ }
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // The matrix renderer.
138
+ // ---------------------------------------------------------------------------
139
+
140
+ const ACTIONS: { label: string; command: string; clause: 'using' | 'check' }[] = [
141
+ { label: 'Read', command: 'SELECT', clause: 'using' },
142
+ { label: 'Create', command: 'INSERT', clause: 'check' },
143
+ { label: 'Update', command: 'UPDATE', clause: 'using' },
144
+ { label: 'Delete', command: 'DELETE', clause: 'using' },
145
+ ];
146
+ const COMMAND_PRIVILEGE: Record<string, string> = { SELECT: 'SELECT', INSERT: 'INSERT', UPDATE: 'UPDATE', DELETE: 'DELETE' };
147
+
148
+ /** Roles to show as rows: the table's grantees (and column-grantees), minus PUBLIC. */
149
+ function tableRoles(t: TableContract): string[] {
150
+ const roles = new Set<string>();
151
+ Object.keys(t.grants).forEach((g) => g.toUpperCase() !== 'PUBLIC' && roles.add(g));
152
+ Object.keys(t.columnGrants ?? {}).forEach((g) => g.toUpperCase() !== 'PUBLIC' && roles.add(g));
153
+ return [...roles].sort();
154
+ }
155
+
156
+ function policiesFor(t: TableContract, role: string, command: string): { permissive: PolicyContract[]; restrictive: PolicyContract[] } {
157
+ const applies = (p: PolicyContract) =>
158
+ (p.command === command || p.command === 'ALL') &&
159
+ (p.roles.includes(role) || p.roles.includes('public'));
160
+ return {
161
+ permissive: t.policies.filter((p) => applies(p) && p.permissive),
162
+ restrictive: t.policies.filter((p) => applies(p) && !p.permissive),
163
+ };
164
+ }
165
+
166
+ /** One matrix cell for (table, role, action). */
167
+ export function renderCell(t: TableContract, role: string, action: typeof ACTIONS[number]): string {
168
+ const priv = COMMAND_PRIVILEGE[action.command];
169
+ if (!hasPrivilege(t, role, priv)) return '—';
170
+ if (!t.rls.enabled) return '✓ all';
171
+
172
+ const { permissive, restrictive } = policiesFor(t, role, action.command);
173
+ if (permissive.length === 0) return '⚠ no policy';
174
+
175
+ const conds = permissive.map((p) => humanizePredicate(p[action.clause]));
176
+ let cell = conds.some((c) => c === 'any row') ? '✓ all' : `✓ ${[...new Set(conds)].join(' OR ')}`;
177
+ if (restrictive.length) {
178
+ cell += ` (only if ${restrictive.map((p) => humanizePredicate(p[action.clause])).join(' AND ')})`;
179
+ }
180
+ // Column-restricted read/write: note the columns when access is column-scoped only.
181
+ const cols = t.columnGrants?.[role]?.[priv];
182
+ if (cols?.length && !(t.grants[role] ?? []).includes(priv)) {
183
+ cell += ` — cols: ${cols.join(', ')}`;
184
+ }
185
+ return cell;
186
+ }
187
+
188
+ /** Render the whole contract as a human-readable Markdown review document. */
189
+ export function renderContractMarkdown(contract: AuthzContract): string {
190
+ const policyCount = contract.tables.reduce((n, t) => n + t.policies.length, 0);
191
+ const lines: string[] = [];
192
+
193
+ lines.push('# Authorization Review');
194
+ lines.push('');
195
+ lines.push('> The human-readable view of `authz/*.contract.json`. Read it to confirm the rules');
196
+ lines.push('> match intent — the JSON is the machine-checked source, this is generated from it.');
197
+ lines.push('> Regenerate with `everystack db:authz:report`; never hand-edit.');
198
+ lines.push('');
199
+ lines.push(`_${contract.tables.length} tables · ${policyCount} policies · ${contract.functions.length} SECURITY DEFINER functions._`);
200
+ lines.push('');
201
+ lines.push('**Legend:**');
202
+ lines.push('');
203
+ lines.push('- **✓** — allowed, followed by the condition a row must satisfy');
204
+ lines.push('- **—** — not granted');
205
+ lines.push('- **⚠ no policy** — the privilege is granted but no RLS policy matches, so no rows are returned (usually a mistake)');
206
+ lines.push('- **owner (`col`)** — the row\'s `col` equals the signed-in user');
207
+ lines.push('- **owner of linked `t` (via `col`)** — you may act when you own the `t` row that `col` points to');
208
+ lines.push('');
209
+
210
+ for (const t of contract.tables) {
211
+ const posture = t.rls.enabled ? (t.rls.forced ? 'enforced (forced)' : 'enabled (not forced)') : '**OFF — no row security**';
212
+ lines.push(`## ${t.table}`);
213
+ lines.push(`RLS: ${posture}`);
214
+ lines.push('');
215
+ const roles = tableRoles(t);
216
+ if (roles.length === 0) {
217
+ lines.push('_No roles granted any access._');
218
+ lines.push('');
219
+ continue;
220
+ }
221
+ lines.push(`| Role | ${ACTIONS.map((a) => a.label).join(' | ')} |`);
222
+ lines.push(`|---|${ACTIONS.map(() => '---').join('|')}|`);
223
+ for (const role of roles) {
224
+ lines.push(`| \`${role}\` | ${ACTIONS.map((a) => renderCell(t, role, a)).join(' | ')} |`);
225
+ }
226
+ lines.push('');
227
+ }
228
+
229
+ if (contract.functions.length) {
230
+ lines.push('## SECURITY DEFINER functions');
231
+ lines.push('');
232
+ lines.push('These run with their owner\'s privileges, **above row security** — their own bodies enforce access. Review each one deliberately. A missing `search_path` is a hijack vector; an owner that bypasses RLS (superuser / BYPASSRLS) makes the function run above RLS as that role — the maximum blast radius. `db:migrate` often leaves functions superuser-owned; normalize ownership to a non-superuser operator.');
233
+ lines.push('');
234
+ const elevated = contract.functions.filter((f) => f.ownerBypassesRls);
235
+ if (elevated.length) {
236
+ lines.push(`> **⚠ ${elevated.length} function(s) are owned by a role that bypasses RLS** — they run above row security regardless of \`search_path\`. Normalize their ownership.`);
237
+ lines.push('');
238
+ }
239
+ for (const f of contract.functions) {
240
+ const sp = f.hasSearchPath ? 'search_path pinned ✓' : '**search_path NOT pinned ⚠**';
241
+ const own = f.ownerBypassesRls ? ' · **owner bypasses RLS ⚠**' : '';
242
+ lines.push(`- \`${f.name}\` — ${sp}${own}`);
243
+ }
244
+ lines.push('');
245
+ }
246
+
247
+ return lines.join('\n');
248
+ }
package/src/cli/aws.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  let s3Client: InstanceType<typeof import('@aws-sdk/client-s3').S3Client> | null = null;
9
9
  let lambdaClient: InstanceType<typeof import('@aws-sdk/client-lambda').LambdaClient> | null = null;
10
10
  let kvsClient: InstanceType<typeof import('@aws-sdk/client-cloudfront-keyvaluestore').CloudFrontKeyValueStoreClient> | null = null;
11
+ let rdsClient: InstanceType<typeof import('@aws-sdk/client-rds').RDSClient> | null = null;
11
12
 
12
13
  async function getS3(region: string) {
13
14
  if (!s3Client) {
@@ -25,6 +26,14 @@ async function getLambda(region: string) {
25
26
  return lambdaClient;
26
27
  }
27
28
 
29
+ async function getRds(region: string) {
30
+ if (!rdsClient) {
31
+ const { RDSClient } = await import('@aws-sdk/client-rds');
32
+ rdsClient = new RDSClient({ region });
33
+ }
34
+ return rdsClient;
35
+ }
36
+
28
37
  export async function uploadToS3(
29
38
  region: string,
30
39
  bucket: string,
@@ -373,3 +382,94 @@ export async function putKvsKey(
373
382
  })
374
383
  );
375
384
  }
385
+
386
+ /** Presign a GET URL for an S3 object (operator IAM). Used by db:backup:download. */
387
+ export async function presignGet(
388
+ region: string,
389
+ bucket: string,
390
+ key: string,
391
+ expiresIn = 3600,
392
+ ): Promise<string> {
393
+ const client = await getS3(region);
394
+ const { GetObjectCommand } = await import('@aws-sdk/client-s3');
395
+ const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
396
+ return await getSignedUrl(client as any, new GetObjectCommand({ Bucket: bucket, Key: key }), { expiresIn });
397
+ }
398
+
399
+ /** A flattened RDS DB snapshot summary, the fields the CLI prints. */
400
+ export interface RdsSnapshotSummary {
401
+ identifier: string;
402
+ status: string;
403
+ /** When the snapshot was first created (stable across copies). */
404
+ created?: Date;
405
+ /** Allocated storage in GiB. */
406
+ sizeGiB?: number;
407
+ engineVersion?: string;
408
+ encrypted?: boolean;
409
+ /** manual | automated | awsbackup | shared | public. */
410
+ type?: string;
411
+ }
412
+
413
+ /**
414
+ * Create a manual RDS DB snapshot of `dbInstanceId` (a physical, instance-level point-in-time image).
415
+ * Control-plane call — authorized purely by the operator's IAM, no VPC. Returns the new snapshot's
416
+ * identifier + initial status (`creating`); the snapshot becomes usable once it reaches `available`.
417
+ */
418
+ export async function createRdsSnapshot(
419
+ region: string,
420
+ dbInstanceId: string,
421
+ snapshotId: string,
422
+ ): Promise<RdsSnapshotSummary> {
423
+ const client = await getRds(region);
424
+ const { CreateDBSnapshotCommand } = await import('@aws-sdk/client-rds');
425
+ const res = await client.send(new CreateDBSnapshotCommand({
426
+ DBInstanceIdentifier: dbInstanceId,
427
+ DBSnapshotIdentifier: snapshotId,
428
+ }));
429
+ const s = res.DBSnapshot ?? {};
430
+ return {
431
+ identifier: s.DBSnapshotIdentifier ?? snapshotId,
432
+ status: s.Status ?? 'creating',
433
+ created: s.SnapshotCreateTime,
434
+ sizeGiB: s.AllocatedStorage,
435
+ engineVersion: s.EngineVersion,
436
+ encrypted: s.Encrypted,
437
+ type: s.SnapshotType,
438
+ };
439
+ }
440
+
441
+ /**
442
+ * List the manual RDS DB snapshots for `dbInstanceId`, newest first. Manual-only by default — the
443
+ * automated/PITR backups RDS takes on its own are noise for this command.
444
+ */
445
+ export async function describeRdsSnapshots(
446
+ region: string,
447
+ dbInstanceId: string,
448
+ ): Promise<RdsSnapshotSummary[]> {
449
+ const client = await getRds(region);
450
+ const { DescribeDBSnapshotsCommand } = await import('@aws-sdk/client-rds');
451
+ const summaries: RdsSnapshotSummary[] = [];
452
+ let marker: string | undefined;
453
+ do {
454
+ const res = await client.send(new DescribeDBSnapshotsCommand({
455
+ DBInstanceIdentifier: dbInstanceId,
456
+ SnapshotType: 'manual',
457
+ Marker: marker,
458
+ }));
459
+ for (const s of res.DBSnapshots ?? []) {
460
+ summaries.push({
461
+ identifier: s.DBSnapshotIdentifier ?? '',
462
+ status: s.Status ?? '',
463
+ created: s.SnapshotCreateTime,
464
+ sizeGiB: s.AllocatedStorage,
465
+ engineVersion: s.EngineVersion,
466
+ encrypted: s.Encrypted,
467
+ type: s.SnapshotType,
468
+ });
469
+ }
470
+ marker = res.Marker;
471
+ } while (marker);
472
+ // Newest first; undated (still-creating) snapshots sort to the top.
473
+ summaries.sort((a, b) => (b.created?.getTime() ?? Infinity) - (a.created?.getTime() ?? Infinity));
474
+ return summaries;
475
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * db:backup / db:restore — the pure core: the S3 key scheme and the cross-stage safety
3
+ * guards for LOGICAL backups (pg_dump custom format, gzipped). The ops actions (pg_dump → S3)
4
+ * and the CLI commands share these, so naming and the confirm policy live in one tested place.
5
+ * See docs/plans/db-backup-restore.md.
6
+ *
7
+ * No IO here — the clock (timestamp) and the short id are passed in, so the mapping is
8
+ * deterministic and unit-tested. Backups are LOGICAL (pg_dump custom format, gzipped),
9
+ * Postgres-native and portable across stages (and across any Postgres, RDS or not).
10
+ */
11
+
12
+ /** Production-tier stage names whose data must not flow downward without explicit intent. */
13
+ const PRODUCTION_TIERS = new Set(['prod', 'production']);
14
+
15
+ /** Is this stage a production tier (its data is sensitive; restores into/from it are gated)? */
16
+ export function isProductionTier(stage: string): boolean {
17
+ return PRODUCTION_TIERS.has(stage.toLowerCase());
18
+ }
19
+
20
+ /** The S3 prefix all of a stage's backups live under (admin-only, encrypted). */
21
+ export function backupPrefix(stage: string): string {
22
+ return `backups/${stage}`;
23
+ }
24
+
25
+ /** The object key for a new backup dump. `stamp` is a UTC `YYYYMMDDTHHMMSSZ` string (caller's clock). */
26
+ export function backupKey(stage: string, stamp: string, shortId: string): string {
27
+ return `${backupPrefix(stage)}/${stamp}-${shortId}.dump.gz`;
28
+ }
29
+
30
+ /** The metadata sidecar key for a backup dump key. */
31
+ export function metaKey(dumpKey: string): string {
32
+ return dumpKey.replace(/\.dump\.gz$/, '.meta.json');
33
+ }
34
+
35
+ /** A stage-qualified backup id — what `list` returns and `restore` consumes: `dev/20260628T120000Z-ab12cd`. */
36
+ export function backupId(stage: string, stamp: string, shortId: string): string {
37
+ return `${stage}/${stamp}-${shortId}`;
38
+ }
39
+
40
+ /** Parse a backup id back into its stage and name, or null when malformed. */
41
+ export function parseBackupRef(id: string): { stage: string; name: string } | null {
42
+ const m = id.match(/^([A-Za-z0-9_-]+)\/([A-Za-z0-9_.:-]+)$/);
43
+ return m ? { stage: m[1], name: m[2] } : null;
44
+ }
45
+
46
+ /** The S3 dump key for a backup id (inverse of backupId), or null when the id is malformed. */
47
+ export function keyForId(id: string): string | null {
48
+ const ref = parseBackupRef(id);
49
+ return ref ? `${backupPrefix(ref.stage)}/${ref.name}.dump.gz` : null;
50
+ }
51
+
52
+ /**
53
+ * Format a Date as the UTC `YYYYMMDDTHHMMSSZ` stamp used in backup keys/ids. The clock is the
54
+ * caller's — pass `new Date()` at the call site so this stays pure and unit-testable.
55
+ */
56
+ export function utcStamp(date: Date): string {
57
+ const p = (n: number): string => String(n).padStart(2, '0');
58
+ return (
59
+ `${date.getUTCFullYear()}${p(date.getUTCMonth() + 1)}${p(date.getUTCDate())}` +
60
+ `T${p(date.getUTCHours())}${p(date.getUTCMinutes())}${p(date.getUTCSeconds())}Z`
61
+ );
62
+ }
63
+
64
+ export interface Guard {
65
+ /** True when the operation needs an explicit confirmation token/flag, not just a default run. */
66
+ requiresConfirm: boolean;
67
+ /** The warning to show when confirmation is required. */
68
+ warning?: string;
69
+ }
70
+
71
+ /**
72
+ * Whether restoring `fromStage` → `toStage` needs explicit confirmation. A production-tier source
73
+ * into a different (non-prod) target drags production data — PII, credentials — downward; that is
74
+ * a data-governance decision the operator must make on purpose, never by accident.
75
+ */
76
+ export function crossStageGuard(fromStage: string, toStage: string): Guard {
77
+ if (fromStage === toStage) return { requiresConfirm: false };
78
+ if (isProductionTier(fromStage) && !isProductionTier(toStage)) {
79
+ return {
80
+ requiresConfirm: true,
81
+ warning: `Restoring from production-tier "${fromStage}" into "${toStage}" copies ALL production data — including PII and credentials — into ${toStage}. This is a data-governance decision; pass --confirm to proceed.`,
82
+ };
83
+ }
84
+ return { requiresConfirm: false };
85
+ }
86
+
87
+ /**
88
+ * Whether a restore INTO `toStage` needs explicit confirmation regardless of source. A restore
89
+ * OVERWRITES the target's database; into a production tier that is always intent-only.
90
+ */
91
+ export function restoreTargetGuard(toStage: string): Guard {
92
+ if (isProductionTier(toStage)) {
93
+ return {
94
+ requiresConfirm: true,
95
+ warning: `Restore OVERWRITES the database of production-tier stage "${toStage}" — every current row is replaced. Pass --confirm to proceed.`,
96
+ };
97
+ }
98
+ return { requiresConfirm: false };
99
+ }