@everystack/cli 0.4.64 → 0.4.66
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/cli/authz-compile.ts +21 -22
- package/src/cli/commands/db.ts +71 -71
- package/src/cli/derived-lint.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.66",
|
|
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>",
|
|
@@ -126,7 +126,7 @@
|
|
|
126
126
|
"structured-headers": "1.0.1",
|
|
127
127
|
"tsx": "4.21.0",
|
|
128
128
|
"typescript": "5.9.3",
|
|
129
|
-
"@everystack/model": "0.4.
|
|
129
|
+
"@everystack/model": "0.4.17"
|
|
130
130
|
},
|
|
131
131
|
"peerDependencies": {
|
|
132
132
|
"@everystack/server": ">=0.4.0",
|
|
@@ -177,7 +177,7 @@
|
|
|
177
177
|
"jest": "29.7.0",
|
|
178
178
|
"react": "19.2.0",
|
|
179
179
|
"ts-jest": "29.4.9",
|
|
180
|
-
"@everystack/server": "0.4.
|
|
180
|
+
"@everystack/server": "0.4.26"
|
|
181
181
|
},
|
|
182
182
|
"scripts": {
|
|
183
183
|
"test": "jest",
|
package/src/cli/authz-compile.ts
CHANGED
|
@@ -331,54 +331,53 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
331
331
|
const isRoleRead = (a: Ability): boolean =>
|
|
332
332
|
a.action === 'read' && Boolean(a.condition.role) && !isColumnAbility(a);
|
|
333
333
|
|
|
334
|
-
//
|
|
335
|
-
//
|
|
334
|
+
// `manage` + owner/via desugars into four verb abilities for policy emission.
|
|
335
|
+
// Role-scoped `manage` (the admin bypass) passes through — it matches `hasAdminManage`
|
|
336
|
+
// directly. Grants handle `manage` natively via `verbsFor`, so this expansion is
|
|
337
|
+
// policy-compiler-local: `compileGrants` still reads `model.abilities`.
|
|
338
|
+
const abilities: readonly Ability[] = model.abilities.flatMap((a) => {
|
|
339
|
+
if (a.action !== 'manage' || !rowScoped(a)) return [a];
|
|
340
|
+
const c = a.condition;
|
|
341
|
+
return [
|
|
342
|
+
{ action: 'read' as const, condition: c },
|
|
343
|
+
{ action: 'create' as const, condition: c },
|
|
344
|
+
{ action: 'update' as const, condition: c },
|
|
345
|
+
{ action: 'delete' as const, condition: c },
|
|
346
|
+
];
|
|
347
|
+
});
|
|
348
|
+
|
|
336
349
|
const predFor = (action: Ability['action']): string | null => {
|
|
337
|
-
for (const a of
|
|
350
|
+
for (const a of abilities) {
|
|
338
351
|
if (a.action !== action || isRoleRead(a) || isColumnAbility(a)) continue;
|
|
339
352
|
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('${action}')`);
|
|
340
353
|
if (p) return p;
|
|
341
354
|
}
|
|
342
355
|
return null;
|
|
343
356
|
};
|
|
344
|
-
/**
|
|
345
|
-
* The predicate on the PUBLIC read — a read ability with neither a role nor a row scope.
|
|
346
|
-
*
|
|
347
|
-
* Read separately from {@link ownerReadPred} because the two are different BRANCHES of
|
|
348
|
-
* the same authenticated policy. Taking both from one ordered lookup made the anon
|
|
349
|
-
* policy depend on the order the abilities happened to be declared in: put the owner
|
|
350
|
-
* read first and `anon` inherited the owner branch's guard as its whole public rule.
|
|
351
|
-
*/
|
|
352
357
|
const publicReadPred = (): string | null => {
|
|
353
|
-
for (const a of
|
|
358
|
+
for (const a of abilities) {
|
|
354
359
|
if (a.action !== 'read' || a.condition.role || rowScoped(a)) continue;
|
|
355
360
|
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read')`);
|
|
356
361
|
if (p) return p;
|
|
357
362
|
}
|
|
358
363
|
return null;
|
|
359
364
|
};
|
|
360
|
-
|
|
361
|
-
/** The predicate the OWNER read narrows ITSELF by — `can('read', { owner, sql })`. */
|
|
362
365
|
const ownerReadPred = (): string | null => {
|
|
363
|
-
for (const a of
|
|
366
|
+
for (const a of abilities) {
|
|
364
367
|
if (a.action !== 'read' || !rowScoped(a) || isColumnAbility(a)) continue;
|
|
365
368
|
const p = rawPredicate(a.condition.sql ?? a.condition.where, `${table}: can('read', { owner })`);
|
|
366
369
|
if (p) return p;
|
|
367
370
|
}
|
|
368
371
|
return null;
|
|
369
372
|
};
|
|
370
|
-
|
|
371
|
-
/** The WRITE half, when the author declared one distinct from the read half. */
|
|
372
373
|
const checkFor = (action: Ability['action']): string | null => {
|
|
373
|
-
for (const a of
|
|
374
|
+
for (const a of abilities) {
|
|
374
375
|
if (a.action !== action) continue;
|
|
375
376
|
const p = rawPredicate(a.condition.check, `${table}: can('${action}', { check })`);
|
|
376
377
|
if (p) return p;
|
|
377
378
|
}
|
|
378
379
|
return null;
|
|
379
380
|
};
|
|
380
|
-
|
|
381
|
-
const abilities = model.abilities;
|
|
382
381
|
const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
|
|
383
382
|
const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
|
|
384
383
|
// The audience is open when the public read says so. Read off the SAME abilities that make
|
|
@@ -569,8 +568,8 @@ export function compileTableContract(model: ModelDescriptor, opts: CompileOption
|
|
|
569
568
|
// key, and undefined must mean ENABLED — the pre-flag behavior — never a silent
|
|
570
569
|
// security downgrade via version skew.
|
|
571
570
|
rls: { enabled: model.rls !== false, forced: model.rls !== false && model.writtenBy === 'app' },
|
|
572
|
-
grants: compileGrants(abilities, model.privileges),
|
|
573
|
-
...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
|
|
571
|
+
grants: compileGrants(model.abilities, model.privileges),
|
|
572
|
+
...(compileColumnGrants(model.abilities) ? { columnGrants: compileColumnGrants(model.abilities) } : {}),
|
|
574
573
|
policies,
|
|
575
574
|
};
|
|
576
575
|
}
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -501,41 +501,27 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
|
|
|
501
501
|
process.exit(1);
|
|
502
502
|
}
|
|
503
503
|
|
|
504
|
-
//
|
|
505
|
-
|
|
506
|
-
const
|
|
507
|
-
if (!adminUrl) {
|
|
508
|
-
fail('No ADMIN_DATABASE_URL secret found. Run db:provision first to set up the credential split.');
|
|
509
|
-
process.exit(1);
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
// Step 0: preflight — verify the master credential works (break-glass path).
|
|
513
|
-
step('Preflight: verifying master connection...');
|
|
504
|
+
// Step 0: preflight — verify the operator connection works (break-glass path).
|
|
505
|
+
step('Preflight: verifying operator connection...');
|
|
506
|
+
const opsFn = opsFunction(config);
|
|
514
507
|
try {
|
|
515
|
-
const result = await invokeAction(config.region,
|
|
508
|
+
const result = await invokeAction(config.region, opsFn, 'db:query', { sql: 'SELECT current_user' });
|
|
516
509
|
if ((result as any)?.error) throw new Error((result as any).error);
|
|
517
|
-
success('
|
|
510
|
+
success('Operator connection verified.');
|
|
518
511
|
} catch (err: any) {
|
|
519
|
-
fail(`
|
|
520
|
-
info('
|
|
512
|
+
fail(`Operator connection failed: ${err.message}`);
|
|
513
|
+
info('Fix the operator connection before rotating.');
|
|
521
514
|
process.exit(1);
|
|
522
515
|
}
|
|
523
516
|
|
|
524
|
-
//
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
execute: async (sql: string) => { await runner(sql); },
|
|
517
|
+
// All DB operations run through the ops Lambda (inside the VPC).
|
|
518
|
+
const rotate = async (action: string, payload?: Record<string, unknown>) => {
|
|
519
|
+
const result = await invokeAction(config.region, opsFn, 'db:rotate', { action, ...payload });
|
|
520
|
+
if ((result as any)?.error) throw new Error((result as any).error);
|
|
521
|
+
return result as any;
|
|
530
522
|
};
|
|
531
523
|
|
|
532
524
|
try {
|
|
533
|
-
const {
|
|
534
|
-
detectActiveRole,
|
|
535
|
-
provisionInactiveRole,
|
|
536
|
-
retireRole,
|
|
537
|
-
drainRole,
|
|
538
|
-
} = await import('@everystack/server/rotate');
|
|
539
525
|
|
|
540
526
|
// Detect which slots to rotate.
|
|
541
527
|
const slotFlag = flags.slot || 'both';
|
|
@@ -552,8 +538,19 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
|
|
|
552
538
|
}
|
|
553
539
|
|
|
554
540
|
const { randomBytes } = await import('node:crypto');
|
|
555
|
-
|
|
556
|
-
|
|
541
|
+
|
|
542
|
+
// Resolve host/port/database from the ops Lambda (same as db:provision does).
|
|
543
|
+
let conn: { host: string; port: string; database: string } | undefined;
|
|
544
|
+
try {
|
|
545
|
+
const psqlInfo = await invokeAction(config.region, opsFn, 'db:psql', {});
|
|
546
|
+
if ((psqlInfo as any)?.host) conn = psqlInfo as any;
|
|
547
|
+
} catch { /* handled below */ }
|
|
548
|
+
if (!conn?.host) {
|
|
549
|
+
fail('Could not resolve database host from the ops Lambda.');
|
|
550
|
+
process.exit(1);
|
|
551
|
+
}
|
|
552
|
+
const sourceUrl = stageSecrets.ADMIN_DATABASE_URL ?? stageSecrets.AdminDatabaseUrl
|
|
553
|
+
?? stageSecrets.DATABASE_URL ?? stageSecrets.DatabaseUrl;
|
|
557
554
|
|
|
558
555
|
// Track the old active role for each slot so retire uses the RIGHT role (F1 fix).
|
|
559
556
|
const oldActiveRoles: Array<{ base: string; oldRole: string }> = [];
|
|
@@ -563,23 +560,22 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
|
|
|
563
560
|
for (const { base, secretKeys } of slots) {
|
|
564
561
|
step(`Rotating ${base}...`);
|
|
565
562
|
|
|
566
|
-
const detected = await
|
|
567
|
-
if (!detected) {
|
|
563
|
+
const detected = await rotate('detect', { baseRole: base });
|
|
564
|
+
if (!detected || (!detected.activeRole && !detected.inactiveRole)) {
|
|
568
565
|
fail(`No rotation pairs found for ${base}. Run db:provision --rotation-pairs first.`);
|
|
569
566
|
process.exit(1);
|
|
570
567
|
}
|
|
571
568
|
info(` Active: ${detected.activeRole}, inactive: ${detected.inactiveRole}`);
|
|
572
569
|
oldActiveRoles.push({ base, oldRole: detected.activeRole });
|
|
573
|
-
const active = detected;
|
|
574
570
|
|
|
575
|
-
// Provision the inactive role.
|
|
571
|
+
// Provision the inactive role via the ops Lambda.
|
|
576
572
|
const password = randomBytes(24).toString('hex');
|
|
577
|
-
await
|
|
578
|
-
success(` ${
|
|
573
|
+
const provisioned = await rotate('provision', { baseRole: base, password });
|
|
574
|
+
success(` ${detected.inactiveRole} provisioned with new password.`);
|
|
579
575
|
|
|
580
576
|
// Build the new URL.
|
|
581
577
|
const newUrl = preserveConnParams(
|
|
582
|
-
`postgresql://${
|
|
578
|
+
`postgresql://${detected.inactiveRole}:${password}@${conn.host}:${conn.port}/${conn.database}`,
|
|
583
579
|
sourceUrl,
|
|
584
580
|
);
|
|
585
581
|
|
|
@@ -658,29 +654,19 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
|
|
|
658
654
|
process.exit(1);
|
|
659
655
|
}
|
|
660
656
|
|
|
661
|
-
// Verify
|
|
662
|
-
//
|
|
657
|
+
// Verify the new credential can log in — prove it works before revoking
|
|
658
|
+
// the old one. Runs inside the VPC via the ops Lambda.
|
|
663
659
|
step('Verifying new credential...');
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
success('New credential connects to the database.');
|
|
674
|
-
} finally {
|
|
675
|
-
await probe.end({ timeout: 1 });
|
|
676
|
-
}
|
|
677
|
-
} else {
|
|
678
|
-
verifyPassed = true;
|
|
660
|
+
const newApiUrl = credEnvOverrides.EVERYSTACK_CRED_DatabaseUrl;
|
|
661
|
+
if (newApiUrl) {
|
|
662
|
+
try {
|
|
663
|
+
await rotate('probe-login', { url: newApiUrl });
|
|
664
|
+
success('New credential connects to the database.');
|
|
665
|
+
} catch (err: any) {
|
|
666
|
+
fail(`New credential login failed: ${err.message}`);
|
|
667
|
+
info('Both roles keep LOGIN (safe state). Fix and re-run.');
|
|
668
|
+
process.exit(1);
|
|
679
669
|
}
|
|
680
|
-
} catch (err: any) {
|
|
681
|
-
fail(`New credential login failed: ${err.message}`);
|
|
682
|
-
info('Both roles keep LOGIN (safe state). Fix and re-run.');
|
|
683
|
-
process.exit(1);
|
|
684
670
|
}
|
|
685
671
|
|
|
686
672
|
// Origin HTTP check (non-blocking — the login probe is the real gate).
|
|
@@ -700,34 +686,48 @@ async function rotateCredentials(flags: Record<string, string>): Promise<void> {
|
|
|
700
686
|
// re-detection would pick the wrong one via the tiebreak).
|
|
701
687
|
for (const { base, oldRole } of oldActiveRoles) {
|
|
702
688
|
step(`Retiring ${oldRole}...`);
|
|
703
|
-
await
|
|
689
|
+
await rotate('retire', { role: oldRole });
|
|
704
690
|
success(` ${oldRole} set to NOLOGIN.`);
|
|
705
691
|
|
|
706
|
-
// Also retire the base role if it still has LOGIN (F6 fix
|
|
707
|
-
// pre-rotation credential must not survive rotation).
|
|
692
|
+
// Also retire the base role if it still has LOGIN (F6 fix).
|
|
708
693
|
try {
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
694
|
+
const baseDetect = await rotate('detect', { baseRole: base });
|
|
695
|
+
if (baseDetect === null) {
|
|
696
|
+
// Base role check — query directly via db:query
|
|
697
|
+
const baseResult = await invokeAction(config.region, opsFn, 'db:query', {
|
|
698
|
+
sql: `SELECT rolcanlogin FROM pg_roles WHERE rolname = '${base}'`,
|
|
699
|
+
});
|
|
700
|
+
const rows = (baseResult as any)?.rows ?? baseResult;
|
|
701
|
+
if (Array.isArray(rows) && rows[0]?.rolcanlogin) {
|
|
702
|
+
await rotate('retire', { role: base });
|
|
703
|
+
success(` ${base} (base role) set to NOLOGIN.`);
|
|
704
|
+
}
|
|
715
705
|
}
|
|
716
706
|
} catch { /* best-effort */ }
|
|
717
707
|
|
|
718
708
|
step(`Draining ${oldRole} sessions (grace: ${graceMs / 1000}s)...`);
|
|
719
|
-
const
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
709
|
+
const deadline = Date.now() + graceMs;
|
|
710
|
+
const POLL_MS = 2000;
|
|
711
|
+
while (Date.now() < deadline) {
|
|
712
|
+
const { count } = await rotate('count-sessions', { role: oldRole });
|
|
713
|
+
if (count === 0) { success(` ${oldRole} drained cleanly.`); break; }
|
|
714
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
715
|
+
}
|
|
716
|
+
const { count: remaining } = await rotate('count-sessions', { role: oldRole });
|
|
717
|
+
if (remaining > 0) {
|
|
718
|
+
const { terminated } = await rotate('terminate-sessions', { role: oldRole });
|
|
723
719
|
success(` ${oldRole} drained (${terminated} session(s) terminated after grace period).`);
|
|
724
720
|
}
|
|
725
721
|
}
|
|
726
722
|
|
|
727
723
|
success('Credential rotation complete.');
|
|
728
724
|
|
|
729
|
-
}
|
|
730
|
-
|
|
725
|
+
} catch (err: any) {
|
|
726
|
+
if (err && (err as any).code !== 'EEXIT') {
|
|
727
|
+
fail(`Rotation error: ${err.message}`);
|
|
728
|
+
process.exit(1);
|
|
729
|
+
}
|
|
730
|
+
throw err;
|
|
731
731
|
}
|
|
732
732
|
}
|
|
733
733
|
|
package/src/cli/derived-lint.ts
CHANGED
|
@@ -189,6 +189,7 @@ export function findSecdefExecuteGaps(derived: readonly DerivedDescriptor[]): De
|
|
|
189
189
|
for (const d of derived) {
|
|
190
190
|
if (d.kind !== 'function' || d.security !== 'definer' || d.abilities.length > 0) continue;
|
|
191
191
|
if (d.returns === 'trigger') continue;
|
|
192
|
+
if (d.internal) continue;
|
|
192
193
|
if (Object.keys(d.privileges ?? {}).length > 0) continue;
|
|
193
194
|
const identity = identityOf(d);
|
|
194
195
|
gaps.push({
|