From e68879b450b434f60b6b4492fbda36e029e62d4e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 18 Aug 2026 10:39:54 +1000 Subject: [PATCH 1/4] feat(cli): defer owner-scoped Supabase grants for non-postgres roles, add `stash eql preflight` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the Lovable-class install failure (#887) and adds the read-only role preflight it called for (#888). - Split the Supabase grants (installer/grants.ts) into plain-GRANT and owner-scoped ALTER DEFAULT PRIVILEGES halves. SUPABASE_PERMISSIONS_SQL_V3 stays byte-identical (locked by a unit test) so the live proof in stack-supabase's grants integration test still covers the shipped SQL. - EQLInstaller.install(): the bundle commits in its own transaction; grants run after it, so one refused grant no longer rolls back ~194 functions. When the connecting role is not a member of postgres, the owner-scoped statements are skipped and returned (InstallResult.deferredGrantsSql) with a header explaining what to do; eql install / eql upgrade print them under 'Deferred SQL — run as postgres'. Failure messages now state recoverability in both directions. - EQLInstaller.preflight() replaces checkPermissions(): one guarded catalogue query also reporting current_user, membership of postgres (null when no postgres role exists — pg_has_role raises otherwise, proven by a live test), and EQL v3 schema presence. - New 'stash eql preflight' command (--json for agents, pure-JSON stdout via a new quiet resolver option), also run at the head of eql install. - Skills: stash-cli (new command, deferral semantics, API surface) and stash-supabase (non-postgres-role note) updated; changeset added. --- .changeset/eql-preflight-deferred-grants.md | 10 + packages/cli/src/__tests__/installer.test.ts | 240 ++++++++++++++++-- packages/cli/src/bin/main.ts | 8 + packages/cli/src/cli/registry.ts | 25 ++ .../commands/db/__tests__/preflight.test.ts | 78 ++++++ packages/cli/src/commands/db/grants-report.ts | 19 ++ packages/cli/src/commands/db/install.ts | 12 +- packages/cli/src/commands/db/preflight.ts | 147 +++++++++++ packages/cli/src/commands/db/status.ts | 2 +- packages/cli/src/commands/db/upgrade.ts | 5 +- packages/cli/src/commands/index.ts | 1 + packages/cli/src/config/database-url.ts | 13 +- packages/cli/src/index.ts | 2 +- .../__tests__/preflight.live.test.ts | 116 +++++++++ packages/cli/src/installer/grants.ts | 81 +++++- packages/cli/src/installer/index.ts | 198 +++++++++++---- .../cli/tests/e2e/command-help.e2e.test.ts | 11 + packages/cli/tests/e2e/smoke.e2e.test.ts | 1 + skills/stash-cli/SKILL.md | 48 +++- skills/stash-supabase/SKILL.md | 9 + 20 files changed, 931 insertions(+), 95 deletions(-) create mode 100644 .changeset/eql-preflight-deferred-grants.md create mode 100644 packages/cli/src/commands/db/__tests__/preflight.test.ts create mode 100644 packages/cli/src/commands/db/grants-report.ts create mode 100644 packages/cli/src/commands/db/preflight.ts create mode 100644 packages/cli/src/installer/__tests__/preflight.live.test.ts diff --git a/.changeset/eql-preflight-deferred-grants.md b/.changeset/eql-preflight-deferred-grants.md new file mode 100644 index 000000000..c058f1155 --- /dev/null +++ b/.changeset/eql-preflight-deferred-grants.md @@ -0,0 +1,10 @@ +--- +'stash': minor +--- + +EQL installs no longer abort on managed platforms whose database role is not `postgres`, and a new `stash eql preflight` command reports role capability before anything is attempted. + +- `stash eql install` (and `eql upgrade`) now run the EQL v3 bundle in its own transaction and the Supabase role grants after it commits, so a grants failure can no longer roll back a working install. When the connecting role is not a member of `postgres` (e.g. Lovable's `sandbox_exec`), the three owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements are skipped and printed under "Deferred SQL — run as postgres" for the operator to apply via a migration tool or the Supabase SQL editor; every plain `GRANT` still runs. Previously that single refused statement rolled back the entire install (~194 functions). +- New read-only `stash eql preflight` (`--json` for agents): reports `current_user`, superuser, membership of `postgres` (guarded for databases with no `postgres` role), `CREATE` on the database and on `public`, `pgcrypto`, and whether the EQL v3 schemas exist — each blocked row naming the statement it blocks. Exits 1 on blocking gaps; membership of `postgres` never blocks. The same check now runs at the head of `eql install`. +- Install failure messages now state recoverability: a bundle failure says nothing was applied (rolled back); a grants failure says the install itself was kept. +- Library surface: `EQLInstaller.checkPermissions()` is replaced by `EQLInstaller.preflight()` (richer `PreflightResult`), and `install()` now returns `InstallResult` with the deferred SQL, if any. The exact `SUPABASE_PERMISSIONS_SQL_V3` block is unchanged byte-for-byte; new exports expose its immediate (`SUPABASE_IMMEDIATE_GRANTS_SQL_V3`) and owner-scoped (`SUPABASE_DEFAULT_PRIVILEGES_SQL_V3`) halves. diff --git a/packages/cli/src/__tests__/installer.test.ts b/packages/cli/src/__tests__/installer.test.ts index 52c23852b..6533f332a 100644 --- a/packages/cli/src/__tests__/installer.test.ts +++ b/packages/cli/src/__tests__/installer.test.ts @@ -14,45 +14,113 @@ vi.mock('pg', () => ({ }, })) +/** A full preflight row with every capability present. */ +const CAPABLE_ROW = { + role_name: 'postgres', + is_superuser: true, + member_of_postgres: true, + has_database_create: true, + has_public_create: true, + pgcrypto_installed: true, + eql_v3_present: false, + eql_v3_internal_present: false, +} + describe('EQLInstaller', () => { beforeEach(() => vi.clearAllMocks()) afterEach(() => vi.restoreAllMocks()) - it('reports sufficient permissions for a superuser', async () => { + it('reports a fully-capable superuser with no gaps', async () => { mockConnect.mockResolvedValue(undefined) - mockQuery.mockResolvedValue({ - rows: [{ rolsuper: true, rolcreatedb: true }], - rowCount: 1, - }) + mockQuery.mockResolvedValue({ rows: [CAPABLE_ROW], rowCount: 1 }) mockEnd.mockResolvedValue(undefined) const { EQLInstaller } = await import('@/installer/index.ts') const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) - await expect(installer.checkPermissions()).resolves.toEqual({ + await expect(installer.preflight()).resolves.toMatchObject({ ok: true, missing: [], isSuperuser: true, + memberOfPostgres: true, + currentUser: 'postgres', }) }) it('accepts database-local CREATE privilege for installing pgcrypto', async () => { mockConnect.mockResolvedValue(undefined) - mockQuery - .mockResolvedValueOnce({ - rows: [{ rolsuper: false, rolcreatedb: false }], - rowCount: 1, - }) - .mockResolvedValueOnce({ rows: [{ has_create: true }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [{ has_create: true }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [], rowCount: 0 }) + mockQuery.mockResolvedValue({ + rows: [ + { + ...CAPABLE_ROW, + role_name: 'app', + is_superuser: false, + member_of_postgres: false, + pgcrypto_installed: false, + }, + ], + rowCount: 1, + }) mockEnd.mockResolvedValue(undefined) const { EQLInstaller } = await import('@/installer/index.ts') const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) - await expect(installer.checkPermissions()).resolves.toEqual({ + await expect(installer.preflight()).resolves.toMatchObject({ ok: true, missing: [], isSuperuser: false, + memberOfPostgres: false, + }) + }) + + it('names each blocking gap for an under-privileged role', async () => { + mockConnect.mockResolvedValue(undefined) + mockQuery.mockResolvedValue({ + rows: [ + { + role_name: 'sandbox_exec', + is_superuser: false, + member_of_postgres: false, + has_database_create: false, + has_public_create: false, + pgcrypto_installed: false, + eql_v3_present: false, + eql_v3_internal_present: false, + }, + ], + rowCount: 1, + }) + mockEnd.mockResolvedValue(undefined) + const { EQLInstaller } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) + + const result = await installer.preflight() + expect(result.ok).toBe(false) + expect(result.missing).toEqual([ + 'CREATE on database (required for CREATE SCHEMA and CREATE EXTENSION)', + 'CREATE on public schema (required for CREATE DOMAIN public.eql_v3_*)', + 'SUPERUSER or extension owner (required for CREATE EXTENSION pgcrypto)', + ]) + }) + + it('reports null membership when the database has no postgres role', async () => { + mockConnect.mockResolvedValue(undefined) + mockQuery.mockResolvedValue({ + rows: [ + { + ...CAPABLE_ROW, + is_superuser: false, + member_of_postgres: null, + }, + ], + rowCount: 1, + }) + mockEnd.mockResolvedValue(undefined) + const { EQLInstaller } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) + + await expect(installer.preflight()).resolves.toMatchObject({ + memberOfPostgres: null, + ok: true, }) }) @@ -90,7 +158,9 @@ describe('EQLInstaller', () => { const { EQLInstaller } = await import('@/installer/index.ts') const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) - await installer.install() + await expect(installer.install()).resolves.toEqual({ + deferredGrantsSql: null, + }) const sqlCall = mockQuery.mock.calls.find( ([sql]) => @@ -102,23 +172,75 @@ describe('EQLInstaller', () => { expect(mockQuery).toHaveBeenCalledWith('COMMIT') }) - it('grants both EQL v3 schemas to Supabase roles', async () => { + it('grants both EQL v3 schemas to Supabase roles when the role is a member of postgres', async () => { mockConnect.mockResolvedValue(undefined) - mockQuery.mockResolvedValue({ rows: [], rowCount: 0 }) + mockQuery.mockImplementation((sql: string) => { + if (typeof sql === 'string' && sql.includes('member_of_postgres')) { + return Promise.resolve({ + rows: [{ member_of_postgres: true }], + rowCount: 1, + }) + } + return Promise.resolve({ rows: [], rowCount: 0 }) + }) mockEnd.mockResolvedValue(undefined) const { EQLInstaller, SUPABASE_PERMISSIONS_SQL_V3 } = await import( '@/installer/index.ts' ) const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) - await installer.install({ supabase: true }) + await expect(installer.install({ supabase: true })).resolves.toEqual({ + deferredGrantsSql: null, + }) expect(mockQuery).toHaveBeenCalledWith(SUPABASE_PERMISSIONS_SQL_V3) expect(SUPABASE_PERMISSIONS_SQL_V3).toContain('eql_v3_internal') expect(SUPABASE_PERMISSIONS_SQL_V3).not.toContain('eql_v2') }) - it('rolls back when the install SQL fails', async () => { + it('defers the owner-scoped grants when the role is not a member of postgres', async () => { + mockConnect.mockResolvedValue(undefined) + mockQuery.mockImplementation((sql: string) => { + if (typeof sql === 'string' && sql.includes('member_of_postgres')) { + return Promise.resolve({ + rows: [{ member_of_postgres: false }], + rowCount: 1, + }) + } + return Promise.resolve({ rows: [], rowCount: 0 }) + }) + mockEnd.mockResolvedValue(undefined) + const { + EQLInstaller, + SUPABASE_DEFAULT_PRIVILEGES_SQL_V3, + SUPABASE_IMMEDIATE_GRANTS_SQL_V3, + SUPABASE_PERMISSIONS_SQL_V3, + } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) + + const result = await installer.install({ supabase: true }) + + expect(mockQuery).toHaveBeenCalledWith(SUPABASE_IMMEDIATE_GRANTS_SQL_V3) + expect(mockQuery).not.toHaveBeenCalledWith(SUPABASE_PERMISSIONS_SQL_V3) + expect(mockQuery).not.toHaveBeenCalledWith( + SUPABASE_DEFAULT_PRIVILEGES_SQL_V3, + ) + expect(result.deferredGrantsSql).toContain( + 'require a role that is a member of `postgres`', + ) + expect(result.deferredGrantsSql).toContain( + SUPABASE_DEFAULT_PRIVILEGES_SQL_V3, + ) + // Nothing owner-scoped in what ran; nothing plain-GRANT in what deferred. + expect(SUPABASE_IMMEDIATE_GRANTS_SQL_V3).not.toContain( + 'ALTER DEFAULT PRIVILEGES', + ) + expect(SUPABASE_DEFAULT_PRIVILEGES_SQL_V3).not.toContain( + 'GRANT USAGE ON SCHEMA', + ) + }) + + it('rolls back when the install SQL fails, and says so', async () => { mockConnect.mockResolvedValue(undefined) mockEnd.mockResolvedValue(undefined) mockQuery.mockImplementation((sql: string) => { @@ -130,7 +252,83 @@ describe('EQLInstaller', () => { const { EQLInstaller } = await import('@/installer/index.ts') const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) - await expect(installer.install()).rejects.toThrow('Failed to install EQL') + await expect(installer.install()).rejects.toThrow( + /Failed to install EQL.*rolled back/s, + ) expect(mockQuery).toHaveBeenCalledWith('ROLLBACK') }) + + it('does not roll back the committed bundle when a grant fails', async () => { + const { EQLInstaller, SUPABASE_PERMISSIONS_SQL_V3 } = await import( + '@/installer/index.ts' + ) + mockConnect.mockResolvedValue(undefined) + mockEnd.mockResolvedValue(undefined) + mockQuery.mockImplementation((sql: string) => { + if (typeof sql === 'string' && sql.includes('member_of_postgres')) { + return Promise.resolve({ + rows: [{ member_of_postgres: true }], + rowCount: 1, + }) + } + if (sql === SUPABASE_PERMISSIONS_SQL_V3) { + return Promise.reject( + new Error('permission denied to change default privileges'), + ) + } + return Promise.resolve({ rows: [], rowCount: 0 }) + }) + const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) + + await expect(installer.install({ supabase: true })).rejects.toThrow( + /EQL v3 is installed.*NOT rolled back/s, + ) + expect(mockQuery).toHaveBeenCalledWith('COMMIT') + expect(mockQuery).not.toHaveBeenCalledWith('ROLLBACK') + }) +}) + +describe('Supabase grants split', () => { + it('keeps SUPABASE_PERMISSIONS_SQL_V3 byte-identical to the pre-split block', async () => { + const { SUPABASE_PERMISSIONS_SQL_V3 } = await import( + '@/installer/grants.ts' + ) + // The exact string the CLI shipped before the immediate/owner-scoped + // split. `packages/stack-supabase/integration/grants.integration.test.ts` + // live-proves this block, so it must not drift. + expect( + SUPABASE_PERMISSIONS_SQL_V3, + ).toBe(`GRANT USAGE ON SCHEMA eql_v3 TO anon, authenticated, service_role; +GRANT SELECT ON ALL TABLES IN SCHEMA eql_v3 TO anon, authenticated, service_role; +GRANT EXECUTE ON ALL ROUTINES IN SCHEMA eql_v3 TO anon, authenticated, service_role; +GRANT USAGE ON ALL SEQUENCES IN SCHEMA eql_v3 TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA eql_v3 GRANT SELECT ON TABLES TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA eql_v3 GRANT EXECUTE ON ROUTINES TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA eql_v3 GRANT USAGE ON SEQUENCES TO anon, authenticated, service_role; +GRANT USAGE ON SCHEMA eql_v3_internal TO anon, authenticated, service_role; +GRANT EXECUTE ON ALL ROUTINES IN SCHEMA eql_v3_internal TO anon, authenticated, service_role; +ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA eql_v3_internal GRANT EXECUTE ON ROUTINES TO anon, authenticated, service_role; +`) + }) + + it('splits every statement into exactly one half', async () => { + const { + SUPABASE_DEFAULT_PRIVILEGES_SQL_V3, + SUPABASE_IMMEDIATE_GRANTS_SQL_V3, + SUPABASE_PERMISSIONS_SQL_V3, + } = await import('@/installer/grants.ts') + const statements = (sql: string) => + sql.split('\n').filter((line) => line.trim() !== '') + const combined = [ + ...statements(SUPABASE_IMMEDIATE_GRANTS_SQL_V3), + ...statements(SUPABASE_DEFAULT_PRIVILEGES_SQL_V3), + ].sort() + expect(combined).toEqual(statements(SUPABASE_PERMISSIONS_SQL_V3).sort()) + for (const line of statements(SUPABASE_DEFAULT_PRIVILEGES_SQL_V3)) { + expect(line).toMatch(/^ALTER DEFAULT PRIVILEGES FOR ROLE postgres /) + } + for (const line of statements(SUPABASE_IMMEDIATE_GRANTS_SQL_V3)) { + expect(line).toMatch(/^GRANT /) + } + }) }) diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index 8b5154000..3746c3ca8 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -32,6 +32,7 @@ import { installCommand, manifestCommand, planCommand, + preflightCommand, statusCommand, telemetryCommand, testConnectionCommand, @@ -108,6 +109,7 @@ Commands: manifest Print the structured, versioned command surface (--json for docs/agents) telemetry Manage anonymous usage analytics (status, enable, disable) + eql preflight Report whether this database role can install EQL, before trying eql install Scaffold stash.config.ts (if missing) and install EQL extensions eql migration Generate an EQL v3 install migration (Drizzle, or supabase/migrations/) eql repair Repair migrations with an un-runnable ALTER COLUMN to an encrypted type @@ -261,6 +263,12 @@ async function runEqlCommand( values: Record, ) { switch (sub) { + case 'preflight': + await preflightCommand({ + databaseUrl: values['database-url'], + json: flags.json, + }) + break case 'install': await runInstall(flags, values) break diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index 4f2bf73c0..7d4854590 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -300,6 +300,31 @@ export const registry: CommandGroup[] = [ { title: 'EQL', commands: [ + { + name: 'eql preflight', + summary: + 'Report whether this database role can install EQL, before trying', + long: [ + 'Read-only: probes the connected role — superuser, membership of', + '`postgres`, CREATE on the database and on `public`, pgcrypto, and', + 'whether the EQL v3 schemas already exist — and names the statement', + 'each gap blocks. Exits 1 when a gap would abort `eql install`.', + '', + 'Membership of `postgres` is reported but never blocks: on managed', + 'platforms whose role is not a member (e.g. Lovable), `eql install`', + 'skips the owner-scoped ALTER DEFAULT PRIVILEGES statements and', + 'prints them for you to apply separately.', + ].join('\n'), + examples: ['eql preflight', 'eql preflight --json'], + flags: [ + { + name: '--json', + description: + 'Emit the machine-readable preflight result instead of the table.', + }, + DATABASE_URL_FLAG, + ], + }, { name: 'eql install', summary: diff --git a/packages/cli/src/commands/db/__tests__/preflight.test.ts b/packages/cli/src/commands/db/__tests__/preflight.test.ts new file mode 100644 index 000000000..796fff152 --- /dev/null +++ b/packages/cli/src/commands/db/__tests__/preflight.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' +import type { PreflightResult } from '@/installer/index.js' +import { renderPreflightReport } from '../preflight.js' + +const CAPABLE: PreflightResult = { + currentUser: 'postgres', + isSuperuser: true, + memberOfPostgres: true, + hasDatabaseCreate: true, + hasPublicCreate: true, + pgcryptoInstalled: true, + eqlV3SchemaPresent: false, + eqlV3InternalSchemaPresent: false, + missing: [], + ok: true, +} + +describe('renderPreflightReport', () => { + it('renders every row, with no annotations for a capable role', () => { + const report = renderPreflightReport(CAPABLE) + expect(report).toContain('current_user') + expect(report).toContain('postgres') + expect(report).toContain('member of postgres yes') + expect(report).toContain('eql_v3 schema') + expect(report).toContain('eql_v3_internal') + expect(report).not.toContain('<- blocks') + }) + + it('annotates a non-member role with the statement it blocks', () => { + const report = renderPreflightReport({ + ...CAPABLE, + currentUser: 'sandbox_exec', + isSuperuser: false, + memberOfPostgres: false, + }) + expect(report).toContain('member of postgres no') + expect(report).toContain( + '<- blocks: ALTER DEFAULT PRIVILEGES FOR ROLE postgres', + ) + }) + + it('marks a database with no postgres role as n/a rather than yes/no', () => { + const report = renderPreflightReport({ + ...CAPABLE, + memberOfPostgres: null, + }) + expect(report).toContain('n/a (no postgres role)') + }) + + it('annotates each missing privilege with what it blocks', () => { + const report = renderPreflightReport({ + ...CAPABLE, + currentUser: 'sandbox_exec', + isSuperuser: false, + memberOfPostgres: false, + hasDatabaseCreate: false, + hasPublicCreate: false, + pgcryptoInstalled: false, + missing: ['x', 'y', 'z'], + ok: false, + }) + expect(report).toContain('<- blocks: CREATE SCHEMA / CREATE EXTENSION') + expect(report).toContain('<- blocks: CREATE DOMAIN public.eql_v3_*') + expect(report).toContain('<- blocks: CREATE EXTENSION pgcrypto') + }) + + it('suppresses privilege annotations for a superuser', () => { + const report = renderPreflightReport({ + ...CAPABLE, + // A superuser row can carry has_* = false on exotic setups; the role + // still installs fine, so nothing should read as blocked. + hasDatabaseCreate: false, + hasPublicCreate: false, + pgcryptoInstalled: false, + }) + expect(report).not.toContain('<- blocks: CREATE') + }) +}) diff --git a/packages/cli/src/commands/db/grants-report.ts b/packages/cli/src/commands/db/grants-report.ts new file mode 100644 index 000000000..318fa5aff --- /dev/null +++ b/packages/cli/src/commands/db/grants-report.ts @@ -0,0 +1,19 @@ +import * as p from '@clack/prompts' +import type { InstallResult } from '@/installer/index.js' + +/** + * Report the Supabase-grants outcome of an `EQLInstaller.install()` run: + * either everything ran, or the owner-scoped default-privilege statements + * were deferred (the connecting role is not a member of `postgres`) and must + * be surfaced for the operator to apply with sufficient privileges. + */ +export function reportSupabaseGrantsOutcome(result: InstallResult): void { + if (result.deferredGrantsSql === null) { + p.log.success('Supabase role permissions granted.') + return + } + p.log.success( + 'Supabase role permissions granted for existing objects. The default-privilege statements below were skipped (they require membership of `postgres`).', + ) + p.note(result.deferredGrantsSql.trim(), 'Deferred SQL — run as postgres') +} diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index f79509eaa..6202947b9 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -9,6 +9,7 @@ import { detectPackageManager, runnerCommand } from '../init/utils.js' import { ensureEncryptionClient } from './client-scaffold.js' import { offerStashConfig } from './config-scaffold.js' import { detectPrismaNext, detectSupabase } from './detect.js' +import { reportSupabaseGrantsOutcome } from './grants-report.js' export const SAFE_MIGRATION_NAME = /^[\w-]+$/ @@ -138,7 +139,7 @@ export async function installCommand( const installer = new EQLInstaller({ databaseUrl }) s.start('Checking database permissions...') - const permissions = await installer.checkPermissions() + const permissions = await installer.preflight() if (!permissions.ok) { s.stop('Insufficient database permissions.') p.log.error('The connected database role is missing required permissions:') @@ -152,6 +153,11 @@ export async function installCommand( } else { s.stop('Database permissions verified.') } + if (supabase && permissions.memberOfPostgres !== true) { + p.log.warn( + `The connected role (${permissions.currentUser}) is not a member of \`postgres\`, so the \`ALTER DEFAULT PRIVILEGES FOR ROLE postgres\` grants cannot run here. The install will proceed; those statements will be printed at the end for you to apply with sufficient privileges.`, + ) + } if (!options.force) { s.start('Checking if EQL is already installed...') @@ -165,9 +171,9 @@ export async function installCommand( } s.start('Installing EQL v3 extensions (pinned bundle)...') - await installer.install({ supabase }) + const installResult = await installer.install({ supabase }) s.stop('EQL extensions installed.') - if (supabase) p.log.success('Supabase role permissions granted.') + if (supabase) reportSupabaseGrantsOutcome(installResult) s.start('Installing cs_migrations tracking schema...') const migrationsDb = new pg.Client({ connectionString: databaseUrl }) diff --git a/packages/cli/src/commands/db/preflight.ts b/packages/cli/src/commands/db/preflight.ts new file mode 100644 index 000000000..4f222621b --- /dev/null +++ b/packages/cli/src/commands/db/preflight.ts @@ -0,0 +1,147 @@ +import * as p from '@clack/prompts' +import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' +import { resolveDatabaseUrl } from '@/config/database-url.js' +import { findConfigFile, loadStashConfig } from '@/config/index.js' +import { EQLInstaller, type PreflightResult } from '@/installer/index.js' + +/** + * Preflight runs BEFORE anything is set up, so a missing stash.config.ts must + * not fail it — fall back to the plain DATABASE_URL resolution chain the + * installer itself uses when no config exists yet. + */ +async function resolvePreflightDatabaseUrl( + databaseUrlFlag: string | undefined, + quiet: boolean, +): Promise { + if (findConfigFile(process.cwd())) { + const config = await loadStashConfig({ databaseUrlFlag, quiet }) + return config.databaseUrl + } + return resolveDatabaseUrl({ databaseUrlFlag, quiet }) +} + +/** + * `stash eql preflight` — read-only "will `eql install` work here, and if not, + * why" report. Runs the same catalogue query the installer runs at the head of + * `eql install`, but as a standalone command an operator (or agent, via + * `--json`) can run before attempting anything. + * + * Exit code: 1 when a blocking gap is present (`result.ok === false`), else 0. + * Membership of `postgres` is reported but never blocks — the installer + * defers the owner-scoped Supabase grants for non-member roles. + */ +export async function preflightCommand( + options: { databaseUrl?: string; json?: boolean } = {}, +): Promise { + if (options.json) { + const databaseUrl = await resolvePreflightDatabaseUrl( + options.databaseUrl, + true, + ) + const installer = new EQLInstaller({ databaseUrl }) + let result: PreflightResult + try { + result = await installer.preflight() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.log(JSON.stringify({ status: 'error', message })) + process.exit(1) + } + console.log(JSON.stringify({ status: 'ok', ...result })) + if (!result.ok) process.exit(1) + return + } + + p.intro(runnerCommand(detectPackageManager(), 'stash eql preflight')) + const s = p.spinner() + + s.start('Resolving database URL...') + const databaseUrl = await resolvePreflightDatabaseUrl( + options.databaseUrl, + false, + ) + s.stop('Database URL resolved.') + + s.start('Probing database role capability...') + const installer = new EQLInstaller({ databaseUrl }) + let result: PreflightResult + try { + result = await installer.preflight() + } catch (error) { + s.stop('Preflight failed.') + p.log.error(error instanceof Error ? error.message : String(error)) + p.outro('Preflight failed.') + process.exit(1) + } + s.stop('Probe complete.') + + p.note(renderPreflightReport(result), 'Database preflight') + + if (!result.ok) { + p.log.error( + 'The connected role cannot run `stash eql install` on this database:', + ) + for (const missing of result.missing) p.log.warn(` - ${missing}`) + p.outro('Preflight found blockers.') + process.exit(1) + } + + if (result.memberOfPostgres !== true) { + p.log.info( + 'Not a member of `postgres`: in Supabase mode the `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` grants will be skipped and printed for you to apply separately. Everything else installs normally.', + ) + } + p.outro('This role can install EQL.') +} + +/** The human-readable rows, aligned. Exported for unit tests. */ +export function renderPreflightReport(result: PreflightResult): string { + const yesNo = (value: boolean) => (value ? 'yes' : 'no') + const rows: Array<[string, string, string?]> = [ + ['current_user', result.currentUser], + ['superuser', yesNo(result.isSuperuser)], + [ + 'member of postgres', + result.memberOfPostgres === null + ? 'n/a (no postgres role)' + : yesNo(result.memberOfPostgres), + result.memberOfPostgres === false + ? '<- blocks: ALTER DEFAULT PRIVILEGES FOR ROLE postgres' + : undefined, + ], + [ + 'CREATE on database', + yesNo(result.hasDatabaseCreate), + result.hasDatabaseCreate || result.isSuperuser + ? undefined + : '<- blocks: CREATE SCHEMA / CREATE EXTENSION', + ], + [ + 'CREATE on public', + yesNo(result.hasPublicCreate), + result.hasPublicCreate || result.isSuperuser + ? undefined + : '<- blocks: CREATE DOMAIN public.eql_v3_*', + ], + [ + 'pgcrypto', + result.pgcryptoInstalled ? 'present' : 'absent', + result.pgcryptoInstalled || result.hasDatabaseCreate || result.isSuperuser + ? undefined + : '<- blocks: CREATE EXTENSION pgcrypto', + ], + ['eql_v3 schema', result.eqlV3SchemaPresent ? 'present' : 'absent'], + [ + 'eql_v3_internal', + result.eqlV3InternalSchemaPresent ? 'present' : 'absent', + ], + ] + const labelWidth = Math.max(...rows.map(([label]) => label.length)) + return rows + .map(([label, value, annotation]) => + [label.padEnd(labelWidth), value, annotation] + .filter((part): part is string => part !== undefined) + .join(' '), + ) + .join('\n') +} diff --git a/packages/cli/src/commands/db/status.ts b/packages/cli/src/commands/db/status.ts index 0b4291f0c..761016682 100644 --- a/packages/cli/src/commands/db/status.ts +++ b/packages/cli/src/commands/db/status.ts @@ -73,7 +73,7 @@ export async function statusCommand(options: { databaseUrl?: string } = {}) { s.start('Checking database permissions...') try { - const permissions = await installer.checkPermissions() + const permissions = await installer.preflight() s.stop('Permissions checked.') if (permissions.ok) { diff --git a/packages/cli/src/commands/db/upgrade.ts b/packages/cli/src/commands/db/upgrade.ts index bd0514aa0..74430604f 100644 --- a/packages/cli/src/commands/db/upgrade.ts +++ b/packages/cli/src/commands/db/upgrade.ts @@ -2,6 +2,7 @@ import * as p from '@clack/prompts' import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' import { loadStashConfig } from '@/config/index.js' import { EQLInstaller } from '@/installer/index.js' +import { reportSupabaseGrantsOutcome } from './grants-report.js' export async function upgradeCommand(options: { dryRun?: boolean @@ -44,9 +45,9 @@ export async function upgradeCommand(options: { } s.start('Upgrading EQL v3 extensions (pinned bundle)...') - await installer.install({ supabase: options.supabase }) + const result = await installer.install({ supabase: options.supabase }) s.stop('EQL extensions upgraded.') - if (options.supabase) p.log.success('Supabase role permissions granted.') + if (options.supabase) reportSupabaseGrantsOutcome(result) s.start('Verifying new version...') const newVersion = await installer.getInstalledVersion() diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 914dbfe38..a9d1b1dd8 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,5 +1,6 @@ export { authCommand } from './auth/index.js' export { installCommand } from './db/install.js' +export { preflightCommand } from './db/preflight.js' export { statusCommand as dbStatusCommand } from './db/status.js' export { testConnectionCommand } from './db/test-connection.js' export { upgradeCommand } from './db/upgrade.js' diff --git a/packages/cli/src/config/database-url.ts b/packages/cli/src/config/database-url.ts index eade79991..cb10acdec 100644 --- a/packages/cli/src/config/database-url.ts +++ b/packages/cli/src/config/database-url.ts @@ -36,6 +36,13 @@ export interface ResolveDatabaseUrlOptions { supabase?: boolean /** Override cwd for project detection (mainly for tests). */ cwd?: string + /** + * Suppress the informational chrome and skip the interactive-prompt tier. + * For `--json` commands: their stdout is a machine-readable contract, so + * neither a "Using DATABASE_URL from …" line nor a prompt may appear on it. + * Error paths still print (and exit 1) — a failure should stay diagnosable. + */ + quiet?: boolean } // The CLI ships as two tsup bundles (`dist/index.js` for the library and @@ -188,7 +195,7 @@ export async function resolveDatabaseUrl( p.log.error(messages.db.urlFlagMalformed) process.exit(1) } - p.log.info(messages.db.urlResolvedFromFlag) + if (!ctx.quiet) p.log.info(messages.db.urlResolvedFromFlag) return trimmed } @@ -203,7 +210,7 @@ export async function resolveDatabaseUrl( if (ctx.supabase || supabaseProject.hasConfigToml) { const fromSupabase = trySupabaseStatus() if (fromSupabase) { - p.log.info(messages.db.urlResolvedFromSupabase) + if (!ctx.quiet) p.log.info(messages.db.urlResolvedFromSupabase) return fromSupabase } } @@ -213,7 +220,7 @@ export async function resolveDatabaseUrl( // CI-truthy spellings (`true`, `1`, case-insensitive) since not every CI // provider sets `CI=true` exactly. const isCi = isCiEnv() - if (isInteractive()) { + if (!ctx.quiet && isInteractive()) { const fromPrompt = await promptForUrl(cwd) if (fromPrompt) { p.log.info(messages.db.urlResolvedFromPrompt) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index fafff0b57..812c41e94 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,5 +5,5 @@ export type { ResolveDatabaseUrlOptions } from './config/database-url.ts' export { resolveDatabaseUrl } from './config/database-url.ts' export type { StashConfig } from './config/index.ts' export { defineConfig, loadStashConfig } from './config/index.ts' -export type { PermissionCheckResult } from './installer/index.ts' +export type { InstallResult, PreflightResult } from './installer/index.ts' export { EQLInstaller, loadBundledEqlSql } from './installer/index.ts' diff --git a/packages/cli/src/installer/__tests__/preflight.live.test.ts b/packages/cli/src/installer/__tests__/preflight.live.test.ts new file mode 100644 index 000000000..80132d168 --- /dev/null +++ b/packages/cli/src/installer/__tests__/preflight.live.test.ts @@ -0,0 +1,116 @@ +/** + * Live-Postgres coverage for `EQLInstaller.preflight()`. + * + * The unit tests feed the preflight a faked row, so they cannot prove the one + * thing this file does: that `PREFLIGHT_SQL` is valid SQL against a real + * server, in BOTH membership arms. The hazard is specific: `pg_has_role` + * raises `role "postgres" does not exist` rather than returning false, so the + * `CASE` guard is load-bearing — and the compose database (`POSTGRES_USER= + * cipherstash`) bootstraps with no `postgres` role, which makes it the + * fixture for the guarded arm. + * + * Gated on STASH_TEST_DATABASE_URL so the default `pnpm test` stays green + * without a database. Locally: + * + * docker compose -f local/docker-compose.postgres.yml up -d --wait + * export STASH_TEST_DATABASE_URL=postgres://cipherstash:password@localhost:55432/cipherstash + */ + +import { afterAll, describe, expect, it } from 'vitest' + +const DATABASE_URL = process.env.STASH_TEST_DATABASE_URL +const describeLive = DATABASE_URL ? describe : describe.skip + +/** Unique per-run so a crashed previous run can't collide. */ +const MEMBER_ROLE = `stash_preflight_member_${process.pid}` +const MEMBER_PASSWORD = 'stash-preflight-test' + +let createdPostgresRole = false + +async function adminQuery(sql: string): Promise { + const { default: pg } = await import('pg') + const client = new pg.Client({ connectionString: DATABASE_URL }) + await client.connect() + try { + const result = await client.query(sql) + return result.rows as T[] + } finally { + await client.end().catch(() => undefined) + } +} + +async function postgresRoleExists(): Promise { + const rows = await adminQuery<{ n: number }>( + "SELECT count(*)::int AS n FROM pg_roles WHERE rolname = 'postgres'", + ) + return rows[0]?.n === 1 +} + +function urlAs(user: string, password: string): string { + const url = new URL(DATABASE_URL as string) + url.username = user + url.password = password + return url.toString() +} + +describeLive('EQLInstaller.preflight — live Postgres', () => { + afterAll(async () => { + await adminQuery(`DROP ROLE IF EXISTS ${MEMBER_ROLE}`).catch( + () => undefined, + ) + if (createdPostgresRole) { + await adminQuery('DROP ROLE IF EXISTS postgres').catch(() => undefined) + } + }) + + it('runs the real preflight query, guarding pg_has_role against a missing postgres role', async () => { + const { EQLInstaller } = await import('../index.js') + const installer = new EQLInstaller({ + databaseUrl: DATABASE_URL as string, + }) + // The assertion that matters is that this does not throw: an unguarded + // pg_has_role('postgres') raises when the role is absent. + const result = await installer.preflight() + expect(result.currentUser).toBe('cipherstash') + expect(result.isSuperuser).toBe(true) + expect(result.ok).toBe(true) + if (await postgresRoleExists()) { + // Image variant that ships a postgres role: a superuser is a member. + expect(result.memberOfPostgres).toBe(true) + } else { + expect(result.memberOfPostgres).toBeNull() + } + }) + + it('reports membership truthfully for member and non-member roles', async () => { + const { EQLInstaller } = await import('../index.js') + if (!(await postgresRoleExists())) { + await adminQuery('CREATE ROLE postgres') + createdPostgresRole = true + } + await adminQuery( + `CREATE ROLE ${MEMBER_ROLE} LOGIN PASSWORD '${MEMBER_PASSWORD}'`, + ) + + // A superuser is a member of every role. + const asSuperuser = await new EQLInstaller({ + databaseUrl: DATABASE_URL as string, + }).preflight() + expect(asSuperuser.memberOfPostgres).toBe(true) + + // A plain login role is not. + const asOutsider = await new EQLInstaller({ + databaseUrl: urlAs(MEMBER_ROLE, MEMBER_PASSWORD), + }).preflight() + expect(asOutsider.currentUser).toBe(MEMBER_ROLE) + expect(asOutsider.isSuperuser).toBe(false) + expect(asOutsider.memberOfPostgres).toBe(false) + + // ...until it is granted membership. + await adminQuery(`GRANT postgres TO ${MEMBER_ROLE}`) + const asMember = await new EQLInstaller({ + databaseUrl: urlAs(MEMBER_ROLE, MEMBER_PASSWORD), + }).preflight() + expect(asMember.memberOfPostgres).toBe(true) + }) +}) diff --git a/packages/cli/src/installer/grants.ts b/packages/cli/src/installer/grants.ts index 9a808a3c2..427e3d3cf 100644 --- a/packages/cli/src/installer/grants.ts +++ b/packages/cli/src/installer/grants.ts @@ -31,11 +31,37 @@ export const EQL_V3_INTERNAL_SCHEMA_NAME = 'eql_v3_internal' * for both the runtime install path and the generated migration file. */ export function supabasePermissionsSql(schemaName: string): string { + return ( + supabaseImmediateGrantsSql(schemaName) + + supabaseDefaultPrivilegesSql(schemaName) + ) +} + +/** + * The plain `GRANT` statements of {@link supabasePermissionsSql} — executable + * by any role that owns the schema (i.e. the role that just ran the EQL + * install), with no membership of `postgres` required. + */ +export function supabaseImmediateGrantsSql(schemaName: string): string { return `GRANT USAGE ON SCHEMA ${schemaName} TO anon, authenticated, service_role; GRANT SELECT ON ALL TABLES IN SCHEMA ${schemaName} TO anon, authenticated, service_role; GRANT EXECUTE ON ALL ROUTINES IN SCHEMA ${schemaName} TO anon, authenticated, service_role; GRANT USAGE ON ALL SEQUENCES IN SCHEMA ${schemaName} TO anon, authenticated, service_role; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT SELECT ON TABLES TO anon, authenticated, service_role; +` +} + +/** + * The owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements of + * {@link supabasePermissionsSql}. These cover objects `postgres` creates in the + * schema *later*, and Postgres only lets a member of `postgres` run them — on + * managed platforms where the connecting role is not (e.g. Lovable's + * `sandbox_exec`), they fail with `permission denied to change default + * privileges` while every statement in + * {@link supabaseImmediateGrantsSql} succeeds. The installer defers them for + * such roles instead of failing the install. + */ +export function supabaseDefaultPrivilegesSql(schemaName: string): string { + return `ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT SELECT ON TABLES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT EXECUTE ON ROUTINES TO anon, authenticated, service_role; ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT USAGE ON SEQUENCES TO anon, authenticated, service_role; ` @@ -58,9 +84,27 @@ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT USAGE O * */ export function supabaseInternalPermissionsSql(schemaName: string): string { + return ( + supabaseInternalImmediateGrantsSql(schemaName) + + supabaseInternalDefaultPrivilegesSql(schemaName) + ) +} + +/** The plain-`GRANT` half of {@link supabaseInternalPermissionsSql}. */ +export function supabaseInternalImmediateGrantsSql(schemaName: string): string { return `GRANT USAGE ON SCHEMA ${schemaName} TO anon, authenticated, service_role; GRANT EXECUTE ON ALL ROUTINES IN SCHEMA ${schemaName} TO anon, authenticated, service_role; -ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT EXECUTE ON ROUTINES TO anon, authenticated, service_role; +` +} + +/** + * The owner-scoped half of {@link supabaseInternalPermissionsSql}. See + * {@link supabaseDefaultPrivilegesSql} for why it is separable. + */ +export function supabaseInternalDefaultPrivilegesSql( + schemaName: string, +): string { + return `ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT EXECUTE ON ROUTINES TO anon, authenticated, service_role; ` } @@ -72,3 +116,36 @@ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA ${schemaName} GRANT EXECUTE export const SUPABASE_PERMISSIONS_SQL_V3 = supabasePermissionsSql(EQL_V3_SCHEMA_NAME) + supabaseInternalPermissionsSql(EQL_V3_INTERNAL_SCHEMA_NAME) + +/** + * The immediate half of {@link SUPABASE_PERMISSIONS_SQL_V3}: every plain + * `GRANT`, both schemas. Runnable by the role that installed EQL, whatever + * its memberships. + */ +export const SUPABASE_IMMEDIATE_GRANTS_SQL_V3 = + supabaseImmediateGrantsSql(EQL_V3_SCHEMA_NAME) + + supabaseInternalImmediateGrantsSql(EQL_V3_INTERNAL_SCHEMA_NAME) + +/** + * The owner-scoped half of {@link SUPABASE_PERMISSIONS_SQL_V3}: the + * `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements, both schemas. + * Requires membership of `postgres`; the installer defers these (and prints + * them, prefixed with {@link DEFERRED_GRANTS_HEADER}) when the connecting + * role is not a member. + * + * Note the two constants reorder statements relative to + * `SUPABASE_PERMISSIONS_SQL_V3` (immediate for both schemas, then deferred + * for both) — the statements are order-independent across schemas, and + * `SUPABASE_PERMISSIONS_SQL_V3` itself keeps its original byte-exact order. + */ +export const SUPABASE_DEFAULT_PRIVILEGES_SQL_V3 = + supabaseDefaultPrivilegesSql(EQL_V3_SCHEMA_NAME) + + supabaseInternalDefaultPrivilegesSql(EQL_V3_INTERNAL_SCHEMA_NAME) + +/** + * Comment prefix for the deferred owner-scoped statements when they are + * printed for the operator (or a future `--print-sql`) instead of executed. + */ +export const DEFERRED_GRANTS_HEADER = `-- The statements below require a role that is a member of \`postgres\`. +-- Apply them via your platform's migration tool or the Supabase SQL editor. +` diff --git a/packages/cli/src/installer/index.ts b/packages/cli/src/installer/index.ts index 6da7ea045..e1b9f7bd0 100644 --- a/packages/cli/src/installer/index.ts +++ b/packages/cli/src/installer/index.ts @@ -1,14 +1,20 @@ import { readInstallSql } from '@cipherstash/eql/sql' import pg from 'pg' import { + DEFERRED_GRANTS_HEADER, EQL_V3_INTERNAL_SCHEMA_NAME, EQL_V3_SCHEMA_NAME, + SUPABASE_DEFAULT_PRIVILEGES_SQL_V3, + SUPABASE_IMMEDIATE_GRANTS_SQL_V3, SUPABASE_PERMISSIONS_SQL_V3, } from './grants.js' export { + DEFERRED_GRANTS_HEADER, EQL_V3_INTERNAL_SCHEMA_NAME, EQL_V3_SCHEMA_NAME, + SUPABASE_DEFAULT_PRIVILEGES_SQL_V3, + SUPABASE_IMMEDIATE_GRANTS_SQL_V3, SUPABASE_PERMISSIONS_SQL_V3, supabaseInternalPermissionsSql, supabasePermissionsSql, @@ -36,12 +42,63 @@ export function supabaseGrantsFor(): string { return SUPABASE_PERMISSIONS_SQL_V3 } -export interface PermissionCheckResult { - ok: boolean - missing: string[] +/** + * Read-only database preflight: everything the install needs from the role, + * gathered before anything is attempted. + * + * `missing` lists only the gaps that abort the install. Membership of + * `postgres` is deliberately NOT one of them — a non-member role installs + * fine; the installer defers the owner-scoped Supabase default-privilege + * statements instead (see {@link InstallResult.deferredGrantsSql}). + */ +export interface PreflightResult { + currentUser: string isSuperuser: boolean + /** + * Whether `current_user` can run `ALTER DEFAULT PRIVILEGES FOR ROLE + * postgres`. `null` when the database has no `postgres` role at all. + */ + memberOfPostgres: boolean | null + hasDatabaseCreate: boolean + hasPublicCreate: boolean + pgcryptoInstalled: boolean + eqlV3SchemaPresent: boolean + eqlV3InternalSchemaPresent: boolean + missing: string[] + ok: boolean } +/** What `install()` actually did, beyond succeeding. */ +export interface InstallResult { + /** + * The owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements + * that were skipped because the connecting role is not a member of + * `postgres` (prefixed with the explanatory header comment), or `null` when + * every grant ran. The caller should surface this SQL for the operator to + * apply via a migration tool or the Supabase SQL editor. + */ + deferredGrantsSql: string | null +} + +/** + * One query answering every preflight question. `pg_has_role` is guarded by + * the `CASE`: it raises on a nonexistent role name, and not every database + * has a `postgres` role. + */ +const PREFLIGHT_SQL = ` + SELECT + current_user AS role_name, + (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) AS is_superuser, + CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres') + THEN pg_has_role(current_user, 'postgres', 'MEMBER') + END AS member_of_postgres, + has_database_privilege(current_user, current_database(), 'CREATE') AS has_database_create, + has_schema_privilege(current_user, 'public', 'CREATE') AS has_public_create, + EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') AS pgcrypto_installed, + EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = '${EQL_V3_SCHEMA_NAME}') AS eql_v3_present, + EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = '${EQL_V3_INTERNAL_SCHEMA_NAME}') AS eql_v3_internal_present +` + export class EQLInstaller { private readonly databaseUrl: string @@ -49,51 +106,47 @@ export class EQLInstaller { this.databaseUrl = options.databaseUrl } - async checkPermissions(): Promise { + async preflight(): Promise { const client = new pg.Client({ connectionString: this.databaseUrl }) try { await client.connect() + const result = await client.query(PREFLIGHT_SQL) + const row = result.rows[0] ?? {} + const isSuperuser = row.is_superuser === true + const hasDatabaseCreate = row.has_database_create === true const missing: string[] = [] - const roleResult = await client.query(` - SELECT rolsuper, rolcreatedb - FROM pg_roles - WHERE rolname = current_user - `) - const role = roleResult.rows[0] - const isSuperuser = role?.rolsuper === true - if (isSuperuser) return { ok: true, missing: [], isSuperuser: true } - - const dbCreateResult = await client.query(` - SELECT has_database_privilege(current_user, current_database(), 'CREATE') AS has_create - `) - if (!dbCreateResult.rows[0]?.has_create) { - missing.push( - 'CREATE on database (required for CREATE SCHEMA and CREATE EXTENSION)', - ) - } - - const schemaCreateResult = await client.query(` - SELECT has_schema_privilege(current_user, 'public', 'CREATE') AS has_create - `) - if (!schemaCreateResult.rows[0]?.has_create) { - missing.push( - 'CREATE on public schema (required for CREATE DOMAIN public.eql_v3_*)', - ) + if (!isSuperuser) { + if (!hasDatabaseCreate) { + missing.push( + 'CREATE on database (required for CREATE SCHEMA and CREATE EXTENSION)', + ) + } + if (row.has_public_create !== true) { + missing.push( + 'CREATE on public schema (required for CREATE DOMAIN public.eql_v3_*)', + ) + } + if (row.pgcrypto_installed !== true && !hasDatabaseCreate) { + missing.push( + 'SUPERUSER or extension owner (required for CREATE EXTENSION pgcrypto)', + ) + } } - - const pgcryptoResult = await client.query(` - SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto' - `) - if ( - (pgcryptoResult.rowCount === 0 || pgcryptoResult.rowCount === null) && - !dbCreateResult.rows[0]?.has_create - ) { - missing.push( - 'SUPERUSER or extension owner (required for CREATE EXTENSION pgcrypto)', - ) + return { + currentUser: String(row.role_name ?? 'unknown'), + isSuperuser, + memberOfPostgres: + typeof row.member_of_postgres === 'boolean' + ? row.member_of_postgres + : null, + hasDatabaseCreate, + hasPublicCreate: row.has_public_create === true, + pgcryptoInstalled: row.pgcrypto_installed === true, + eqlV3SchemaPresent: row.eql_v3_present === true, + eqlV3InternalSchemaPresent: row.eql_v3_internal_present === true, + missing, + ok: missing.length === 0, } - - return { ok: missing.length === 0, missing, isSuperuser: false } } catch (error) { const detail = error instanceof Error ? error.message : String(error) throw new Error(`Failed to connect to database: ${detail}`, { @@ -165,8 +218,20 @@ export class EQLInstaller { } } - /** Install the pinned EQL v3 bundle. */ - async install(options?: { supabase?: boolean }): Promise { + /** + * Install the pinned EQL v3 bundle, then (in Supabase mode) the role + * grants. + * + * The bundle runs in its own transaction. The grants deliberately run + * AFTER its COMMIT: they are idempotent and separately re-runnable, so a + * grants failure must not roll back a working install — one refused + * statement used to take all ~194 functions down with it. When the + * connecting role is not a member of `postgres`, the owner-scoped + * `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements are skipped and + * returned for the operator to apply with sufficient privileges; the plain + * `GRANT`s still run, so everything that exists is usable immediately. + */ + async install(options?: { supabase?: boolean }): Promise { const client = new pg.Client({ connectionString: this.databaseUrl }) try { await client.connect() @@ -178,16 +243,43 @@ export class EQLInstaller { } try { - await client.query('BEGIN') - await client.query(loadBundledEqlSql()) - if (options?.supabase) { - await client.query(SUPABASE_PERMISSIONS_SQL_V3) + try { + await client.query('BEGIN') + await client.query(loadBundledEqlSql()) + await client.query('COMMIT') + } catch (error) { + await client.query('ROLLBACK').catch(() => {}) + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `Failed to install EQL: ${detail}. Nothing was applied — the install runs in a transaction and was rolled back.`, + { cause: error }, + ) + } + + if (!options?.supabase) return { deferredGrantsSql: null } + + try { + const memberResult = await client.query(` + SELECT CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres') + THEN pg_has_role(current_user, 'postgres', 'MEMBER') + END AS member_of_postgres + `) + if (memberResult.rows[0]?.member_of_postgres === true) { + await client.query(SUPABASE_PERMISSIONS_SQL_V3) + return { deferredGrantsSql: null } + } + await client.query(SUPABASE_IMMEDIATE_GRANTS_SQL_V3) + return { + deferredGrantsSql: + DEFERRED_GRANTS_HEADER + SUPABASE_DEFAULT_PRIVILEGES_SQL_V3, + } + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error( + `EQL v3 is installed, but granting the Supabase roles failed: ${detail}. The install itself was NOT rolled back — apply the grants from \`stash eql migration --supabase\`, or re-run \`stash eql install --force\`.`, + { cause: error }, + ) } - await client.query('COMMIT') - } catch (error) { - await client.query('ROLLBACK').catch(() => {}) - const detail = error instanceof Error ? error.message : String(error) - throw new Error(`Failed to install EQL: ${detail}`, { cause: error }) } finally { await client.end() } diff --git a/packages/cli/tests/e2e/command-help.e2e.test.ts b/packages/cli/tests/e2e/command-help.e2e.test.ts index 6353feb02..ef4b62c85 100644 --- a/packages/cli/tests/e2e/command-help.e2e.test.ts +++ b/packages/cli/tests/e2e/command-help.e2e.test.ts @@ -16,6 +16,7 @@ describe('per-command --help', () => { }) expect(r.exitCode).toBe(0) expect(r.output).toContain('Usage: npx stash eql [options]') + expect(r.output).toContain('eql preflight') expect(r.output).toContain('eql install') expect(r.output).toContain('eql migration') expect(r.output).toContain('eql repair') @@ -49,6 +50,16 @@ describe('per-command --help', () => { expect(r.output).toContain('--force') }) + it('renders full command help for `eql preflight --help`', async () => { + const r = await run(['eql', 'preflight', '--help'], { + env: { npm_config_user_agent: '' }, + }) + expect(r.exitCode).toBe(0) + expect(r.output).toContain('Usage: npx stash eql preflight [options]') + expect(r.output).toContain('--json') + expect(r.output).toContain('--database-url') + }) + it('renders full command help for `eql install --help`', async () => { const r = await run(['eql', 'install', '--help'], { env: { npm_config_user_agent: '' }, diff --git a/packages/cli/tests/e2e/smoke.e2e.test.ts b/packages/cli/tests/e2e/smoke.e2e.test.ts index 9c561ca00..034658253 100644 --- a/packages/cli/tests/e2e/smoke.e2e.test.ts +++ b/packages/cli/tests/e2e/smoke.e2e.test.ts @@ -22,6 +22,7 @@ describe('stash CLI — non-interactive smoke', () => { // Command-list items — these are the literal command names users type, not // copy strings, so they stay inline. expect(r.output).toContain('init') + expect(r.output).toContain('eql preflight') expect(r.output).toContain('eql install') expect(r.output).toContain('eql migration') expect(r.output).toContain('eql repair') diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 18453d1b2..46cfa7e03 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: stash-cli -description: Drive CipherStash setup and encryption migrations through the `stash` CLI — `init`, `plan`, `impl`, `status`, `auth login`, `eql install/migration/repair/upgrade/status/validate`, `encrypt backfill/drop`, `schema build`, and `manifest --json`. Covers the agent / non-interactive interface, credential rules, and the staged EQL v3 rollout lifecycle. +description: Drive CipherStash setup and encryption migrations through the `stash` CLI — `init`, `plan`, `impl`, `status`, `auth login`, `eql preflight/install/migration/repair/upgrade/status/validate`, `encrypt backfill/drop`, `schema build`, and `manifest --json`. Covers the agent / non-interactive interface, credential rules, and the staged EQL v3 rollout lifecycle. --- # CipherStash CLI (`stash`) @@ -339,6 +339,7 @@ Flags below are the decision-relevant ones. Run `stash --help` for the ### EQL ```bash +stash eql preflight stash eql install stash eql migration --drizzle stash eql migration --supabase @@ -349,6 +350,17 @@ stash eql status > `stash db install`, `db upgrade`, and `db status` still work but print a deprecation warning and forward to `eql `. Use the `eql` spelling. +#### `eql preflight` + +Read-only report of whether the connected role can install EQL, run before anything is attempted. It probes: `current_user`, superuser, **membership of `postgres`**, `CREATE` on the database and on `public`, `pgcrypto`, and whether the `eql_v3` / `eql_v3_internal` schemas already exist. Each blocked row names the statement it blocks. Exits 1 when a gap would abort `eql install`; `--json` emits the structured result for agents (stdout is pure JSON). + +Membership of `postgres` is reported but never blocks: `eql install` handles a non-member role by deferring the owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements (see `eql install` below). This matters on managed platforms whose database role is not `postgres` and not a member of it (e.g. Lovable's `sandbox_exec`). + +| Flag | Description | +|---|---| +| `--json` | Machine-readable result instead of the table | +| `--database-url ` | Probe that database (no config needed) | + #### `eql install` Gets a project from zero to a direct EQL v3 install. It loads an existing `stash.config.ts` (or offers to scaffold one), scaffolds the encryption client if missing, and applies the pinned `@cipherstash/eql` bundle. To put the installation in migration history instead, use `eql migration` — `--drizzle` for Drizzle, `--supabase` for a Supabase project. @@ -360,6 +372,8 @@ Gets a project from zero to a direct EQL v3 install. It loads an existing `stash | `--force` | Reinstall even if EQL is present | | `--dry-run` | Show what would happen | | `--supabase` | Supabase-compatible install; grants `anon`, `authenticated`, and `service_role` | + +**Non-`postgres` roles.** The Supabase grants include three owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements that only a member of `postgres` can run. When the connecting role is not a member (checked up front), the install still succeeds: the bundle and every plain `GRANT` are applied, and the deferred statements are printed at the end under "Deferred SQL — run as postgres" for you to apply via your platform's migration tool or the Supabase SQL editor. Never work around a grants failure by disabling anything — the install itself is no longer rolled back by a grants failure (the bundle commits in its own transaction; grants run after it). | `--database-url ` | One-shot install (see below) | The removed `--eql-version`, `--latest`, `--drizzle`, `--migration`, `--direct`, `--migrations-dir`, and `--exclude-operator-family` options fail clearly instead of being ignored. A request for EQL v2 points dump-recovery users to the upstream EQL 2.3.1 SQL release. New installs are EQL v3 only; its pinned bundle self-adapts when a database role cannot create the optional operator family. @@ -664,34 +678,50 @@ import { ```typescript const installer = new EQLInstaller({ databaseUrl: 'postgresql://...' }) -await installer.checkPermissions() // PermissionCheckResult +await installer.preflight() // PreflightResult await installer.isInstalled() // boolean (v3) await installer.getInstalledVersion() // string | 'unknown' | null -await installer.install({ supabase: true }) // executes in a transaction +await installer.install({ supabase: true }) // InstallResult ``` `install` installs EQL v3 only and accepts `supabase`. `isInstalled` and `getInstalledVersion` retain an optional `{ eqlVersion: 2 | 3 }` solely for read-only diagnostics of existing v2 databases. ```typescript -type PermissionCheckResult = { - ok: boolean // all required permissions present - missing: string[] // what's absent - isSuperuser: boolean // permission diagnostic; the v3 bundle self-adapts +type PreflightResult = { + ok: boolean // no blocking gaps + missing: string[] // blocking gaps, each naming what it blocks + currentUser: string + isSuperuser: boolean + memberOfPostgres: boolean | null // null: no postgres role exists; false never blocks + hasDatabaseCreate: boolean + hasPublicCreate: boolean + pgcryptoInstalled: boolean + eqlV3SchemaPresent: boolean + eqlV3InternalSchemaPresent: boolean +} + +type InstallResult = { + // The skipped ALTER DEFAULT PRIVILEGES FOR ROLE postgres statements (with an + // explanatory header) when the role is not a member of postgres; null when + // every grant ran. Surface it for the operator to apply. + deferredGrantsSql: string | null } ``` +The bundle runs in its own transaction; the Supabase grants run after its commit, so a grants failure no longer rolls back a working install. + Required: `SUPERUSER`, **or** `CREATE` on the database *and* on the `public` schema. If `pgcrypto` is absent, also `SUPERUSER` or `CREATEDB`. ## Requirements - Node.js >= 22 -- PostgreSQL with sufficient permissions (see `checkPermissions()`) +- PostgreSQL with sufficient permissions (check with `stash eql preflight`) - `stash.config.ts` with a valid `databaseUrl` — or run `stash init` / `stash eql install` to scaffold it - Optional peer dependency: `@cipherstash/stack` >= 0.6.0 (required for the commands that load your encryption client) ## Common issues -**Permission errors during install.** The role needs `CREATE` on the database and the `public` schema, or `SUPERUSER`. Check the CLI output for exactly what's missing. +**Permission errors during install.** The role needs `CREATE` on the database and the `public` schema, or `SUPERUSER`. Run `stash eql preflight` for a row-by-row report of exactly what's missing and which statement each gap blocks. **Config not found.** `stash.config.ts` must be in the project root or a parent, and must `export default defineConfig(...)`. Fastest fix: `stash init`. For a CLI-only setup, `stash eql install` scaffolds it too. diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 71e5ece46..18d14c1d4 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -89,6 +89,15 @@ supabase db push # remote/linked project > `stash eql install --supabase` is for a **hosted** project you administer > without the Supabase CLI, where there is no migrations directory to write to. +> **Connecting as a role that is not `postgres` (or a member of it)** — common +> on managed AI platforms such as Lovable — is fine: the install proceeds, and +> only the three owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` +> statements are skipped and printed at the end under "Deferred SQL — run as +> postgres". Apply them via the Supabase SQL editor or your platform's +> migration tool. Check ahead of time with `stash eql preflight` (`--json` for +> agents), which reports membership of `postgres` alongside the other role +> capabilities. + The generated file carries three things, in order: the EQL v3 bundle, the role grants, and the `cipherstash.cs_migrations` tracking schema that `stash encrypt` records per-column progress in. One `supabase db reset` therefore From 8714b3a974b724504beed0718ffd41398a47566e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 18 Aug 2026 11:19:19 +1000 Subject: [PATCH 2/4] refactor(cli): frame the skipped owner-scoped grants as optional, not deferred work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on the deferral design: every path that creates new EQL objects through stash tooling re-grants them itself — install --force and eql upgrade re-run the blanket grants, and the generated Supabase migration embeds them alongside the bundle. The ALTER DEFAULT PRIVILEGES FOR ROLE postgres statements therefore only cover objects postgres might create in the EQL schemas outside stash tooling, and on platforms where no operator can act as postgres (Lovable) they can simply never be applied without anything being lost. Reword accordingly: the install reports itself complete, the skipped SQL prints under 'Optional SQL — requires postgres' with the header explaining when it would matter, the pre-install notice drops from warn to info, and the preflight row annotation says 'skips optional' rather than 'blocks'. Skills and changeset updated to match. No behavioural change to what runs against the database. --- .changeset/eql-preflight-deferred-grants.md | 2 +- packages/cli/src/cli/registry.ts | 5 +++-- .../commands/db/__tests__/preflight.test.ts | 2 +- packages/cli/src/commands/db/grants-report.ts | 21 ++++++++++++------- packages/cli/src/commands/db/install.ts | 4 ++-- packages/cli/src/commands/db/preflight.ts | 4 ++-- packages/cli/src/installer/grants.ts | 17 +++++++++++---- packages/cli/src/installer/index.ts | 11 ++++++---- skills/stash-cli/SKILL.md | 9 ++++---- skills/stash-supabase/SKILL.md | 17 ++++++++------- 10 files changed, 58 insertions(+), 34 deletions(-) diff --git a/.changeset/eql-preflight-deferred-grants.md b/.changeset/eql-preflight-deferred-grants.md index c058f1155..c7a2a3836 100644 --- a/.changeset/eql-preflight-deferred-grants.md +++ b/.changeset/eql-preflight-deferred-grants.md @@ -4,7 +4,7 @@ EQL installs no longer abort on managed platforms whose database role is not `postgres`, and a new `stash eql preflight` command reports role capability before anything is attempted. -- `stash eql install` (and `eql upgrade`) now run the EQL v3 bundle in its own transaction and the Supabase role grants after it commits, so a grants failure can no longer roll back a working install. When the connecting role is not a member of `postgres` (e.g. Lovable's `sandbox_exec`), the three owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements are skipped and printed under "Deferred SQL — run as postgres" for the operator to apply via a migration tool or the Supabase SQL editor; every plain `GRANT` still runs. Previously that single refused statement rolled back the entire install (~194 functions). +- `stash eql install` (and `eql upgrade`) now run the EQL v3 bundle in its own transaction and the Supabase role grants after it commits, so a grants failure can no longer roll back a working install. When the connecting role is not a member of `postgres` (e.g. Lovable's `sandbox_exec`), the three owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements are skipped and the install completes without them — they are optional (they only cover EQL objects `postgres` might later create outside stash tooling, and stash re-grants every object on each install/upgrade); the SQL is printed as "Optional SQL — requires postgres" for operators who want it. Every plain `GRANT` still runs. Previously that single refused statement rolled back the entire install (~194 functions). - New read-only `stash eql preflight` (`--json` for agents): reports `current_user`, superuser, membership of `postgres` (guarded for databases with no `postgres` role), `CREATE` on the database and on `public`, `pgcrypto`, and whether the EQL v3 schemas exist — each blocked row naming the statement it blocks. Exits 1 on blocking gaps; membership of `postgres` never blocks. The same check now runs at the head of `eql install`. - Install failure messages now state recoverability: a bundle failure says nothing was applied (rolled back); a grants failure says the install itself was kept. - Library surface: `EQLInstaller.checkPermissions()` is replaced by `EQLInstaller.preflight()` (richer `PreflightResult`), and `install()` now returns `InstallResult` with the deferred SQL, if any. The exact `SUPABASE_PERMISSIONS_SQL_V3` block is unchanged byte-for-byte; new exports expose its immediate (`SUPABASE_IMMEDIATE_GRANTS_SQL_V3`) and owner-scoped (`SUPABASE_DEFAULT_PRIVILEGES_SQL_V3`) halves. diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index 7d4854590..db05cffed 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -312,8 +312,9 @@ export const registry: CommandGroup[] = [ '', 'Membership of `postgres` is reported but never blocks: on managed', 'platforms whose role is not a member (e.g. Lovable), `eql install`', - 'skips the owner-scoped ALTER DEFAULT PRIVILEGES statements and', - 'prints them for you to apply separately.', + 'skips the optional owner-scoped ALTER DEFAULT PRIVILEGES statements', + '— the install is complete without them, since stash re-grants every', + 'object on each install/upgrade.', ].join('\n'), examples: ['eql preflight', 'eql preflight --json'], flags: [ diff --git a/packages/cli/src/commands/db/__tests__/preflight.test.ts b/packages/cli/src/commands/db/__tests__/preflight.test.ts index 796fff152..1b6330b7f 100644 --- a/packages/cli/src/commands/db/__tests__/preflight.test.ts +++ b/packages/cli/src/commands/db/__tests__/preflight.test.ts @@ -35,7 +35,7 @@ describe('renderPreflightReport', () => { }) expect(report).toContain('member of postgres no') expect(report).toContain( - '<- blocks: ALTER DEFAULT PRIVILEGES FOR ROLE postgres', + '<- skips optional: ALTER DEFAULT PRIVILEGES FOR ROLE postgres', ) }) diff --git a/packages/cli/src/commands/db/grants-report.ts b/packages/cli/src/commands/db/grants-report.ts index 318fa5aff..06bfc6c08 100644 --- a/packages/cli/src/commands/db/grants-report.ts +++ b/packages/cli/src/commands/db/grants-report.ts @@ -2,18 +2,25 @@ import * as p from '@clack/prompts' import type { InstallResult } from '@/installer/index.js' /** - * Report the Supabase-grants outcome of an `EQLInstaller.install()` run: - * either everything ran, or the owner-scoped default-privilege statements - * were deferred (the connecting role is not a member of `postgres`) and must - * be surfaced for the operator to apply with sufficient privileges. + * Report the Supabase-grants outcome of an `EQLInstaller.install()` run. + * + * When the connecting role is not a member of `postgres`, the owner-scoped + * `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements were skipped. That + * is reported as information, not as work the operator owes: the statements + * only cover EQL objects `postgres` might create later outside stash tooling, + * and every `stash eql install`/`eql upgrade` re-grants all objects anyway + * (the generated Supabase migration embeds the grants too). On platforms + * where nobody can act as `postgres` — Lovable's `sandbox_exec`, for one — + * there is nothing to do and nothing missing. */ export function reportSupabaseGrantsOutcome(result: InstallResult): void { if (result.deferredGrantsSql === null) { p.log.success('Supabase role permissions granted.') return } - p.log.success( - 'Supabase role permissions granted for existing objects. The default-privilege statements below were skipped (they require membership of `postgres`).', + p.log.success('Supabase role permissions granted for all existing objects.') + p.log.info( + 'Skipped the optional `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements — they require membership of `postgres`, and are only needed if EQL objects are later created outside stash tooling (stash re-grants every object on each install/upgrade). To apply them anyway, use the SQL below via your migration tool or the Supabase SQL editor.', ) - p.note(result.deferredGrantsSql.trim(), 'Deferred SQL — run as postgres') + p.note(result.deferredGrantsSql.trim(), 'Optional SQL — requires postgres') } diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index 6202947b9..f53e06102 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -154,8 +154,8 @@ export async function installCommand( s.stop('Database permissions verified.') } if (supabase && permissions.memberOfPostgres !== true) { - p.log.warn( - `The connected role (${permissions.currentUser}) is not a member of \`postgres\`, so the \`ALTER DEFAULT PRIVILEGES FOR ROLE postgres\` grants cannot run here. The install will proceed; those statements will be printed at the end for you to apply with sufficient privileges.`, + p.log.info( + `The connected role (${permissions.currentUser}) is not a member of \`postgres\`, so the optional \`ALTER DEFAULT PRIVILEGES FOR ROLE postgres\` statements will be skipped. The install proceeds and is complete without them — stash re-grants every object on each install/upgrade.`, ) } diff --git a/packages/cli/src/commands/db/preflight.ts b/packages/cli/src/commands/db/preflight.ts index 4f222621b..9fbd8ec34 100644 --- a/packages/cli/src/commands/db/preflight.ts +++ b/packages/cli/src/commands/db/preflight.ts @@ -88,7 +88,7 @@ export async function preflightCommand( if (result.memberOfPostgres !== true) { p.log.info( - 'Not a member of `postgres`: in Supabase mode the `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` grants will be skipped and printed for you to apply separately. Everything else installs normally.', + 'Not a member of `postgres`: in Supabase mode the optional `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements are skipped. The install is complete without them — they only cover EQL objects created outside stash tooling, and stash re-grants every object on each install/upgrade.', ) } p.outro('This role can install EQL.') @@ -106,7 +106,7 @@ export function renderPreflightReport(result: PreflightResult): string { ? 'n/a (no postgres role)' : yesNo(result.memberOfPostgres), result.memberOfPostgres === false - ? '<- blocks: ALTER DEFAULT PRIVILEGES FOR ROLE postgres' + ? '<- skips optional: ALTER DEFAULT PRIVILEGES FOR ROLE postgres' : undefined, ], [ diff --git a/packages/cli/src/installer/grants.ts b/packages/cli/src/installer/grants.ts index 427e3d3cf..be044f3c7 100644 --- a/packages/cli/src/installer/grants.ts +++ b/packages/cli/src/installer/grants.ts @@ -143,9 +143,18 @@ export const SUPABASE_DEFAULT_PRIVILEGES_SQL_V3 = supabaseInternalDefaultPrivilegesSql(EQL_V3_INTERNAL_SCHEMA_NAME) /** - * Comment prefix for the deferred owner-scoped statements when they are - * printed for the operator (or a future `--print-sql`) instead of executed. + * Comment prefix for the skipped owner-scoped statements when they are + * surfaced for the operator (or a future `--print-sql`) instead of executed. + * + * Framed as OPTIONAL, deliberately: every `stash eql install` / `eql upgrade` + * re-runs the blanket grants over all objects, and the generated Supabase + * migration embeds them alongside the bundle — so the default-privileges + * rules only matter for EQL objects created outside stash tooling. On + * platforms where no operator can act as `postgres` (e.g. Lovable), nothing + * is lost by never applying them. */ -export const DEFERRED_GRANTS_HEADER = `-- The statements below require a role that is a member of \`postgres\`. --- Apply them via your platform's migration tool or the Supabase SQL editor. +export const DEFERRED_GRANTS_HEADER = `-- Optional: the statements below require a role that is a member of \`postgres\`. +-- They are only needed if EQL objects are later created outside stash tooling +-- (stash re-grants every object on each install/upgrade). If you want them, +-- apply them via your platform's migration tool or the Supabase SQL editor. ` diff --git a/packages/cli/src/installer/index.ts b/packages/cli/src/installer/index.ts index e1b9f7bd0..62c791080 100644 --- a/packages/cli/src/installer/index.ts +++ b/packages/cli/src/installer/index.ts @@ -74,8 +74,10 @@ export interface InstallResult { * The owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements * that were skipped because the connecting role is not a member of * `postgres` (prefixed with the explanatory header comment), or `null` when - * every grant ran. The caller should surface this SQL for the operator to - * apply via a migration tool or the Supabase SQL editor. + * every grant ran. OPTIONAL from the operator's perspective: they only + * cover EQL objects created outside stash tooling, and every + * install/upgrade re-grants all objects — surface the SQL as information, + * not as a required step. */ deferredGrantsSql: string | null } @@ -228,8 +230,9 @@ export class EQLInstaller { * statement used to take all ~194 functions down with it. When the * connecting role is not a member of `postgres`, the owner-scoped * `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements are skipped and - * returned for the operator to apply with sufficient privileges; the plain - * `GRANT`s still run, so everything that exists is usable immediately. + * returned as optional SQL; the plain `GRANT`s still run, so everything + * that exists is usable immediately, and stash re-grants on every + * install/upgrade — the install is complete without them. */ async install(options?: { supabase?: boolean }): Promise { const client = new pg.Client({ connectionString: this.databaseUrl }) diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 46cfa7e03..a40065441 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -354,7 +354,7 @@ stash eql status Read-only report of whether the connected role can install EQL, run before anything is attempted. It probes: `current_user`, superuser, **membership of `postgres`**, `CREATE` on the database and on `public`, `pgcrypto`, and whether the `eql_v3` / `eql_v3_internal` schemas already exist. Each blocked row names the statement it blocks. Exits 1 when a gap would abort `eql install`; `--json` emits the structured result for agents (stdout is pure JSON). -Membership of `postgres` is reported but never blocks: `eql install` handles a non-member role by deferring the owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements (see `eql install` below). This matters on managed platforms whose database role is not `postgres` and not a member of it (e.g. Lovable's `sandbox_exec`). +Membership of `postgres` is reported but never blocks: `eql install` handles a non-member role by skipping the owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements, which are **optional** — the install is complete without them (see `eql install` below). This matters on managed platforms whose database role is not `postgres` and not a member of it (e.g. Lovable's `sandbox_exec`). | Flag | Description | |---|---| @@ -373,7 +373,7 @@ Gets a project from zero to a direct EQL v3 install. It loads an existing `stash | `--dry-run` | Show what would happen | | `--supabase` | Supabase-compatible install; grants `anon`, `authenticated`, and `service_role` | -**Non-`postgres` roles.** The Supabase grants include three owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements that only a member of `postgres` can run. When the connecting role is not a member (checked up front), the install still succeeds: the bundle and every plain `GRANT` are applied, and the deferred statements are printed at the end under "Deferred SQL — run as postgres" for you to apply via your platform's migration tool or the Supabase SQL editor. Never work around a grants failure by disabling anything — the install itself is no longer rolled back by a grants failure (the bundle commits in its own transaction; grants run after it). +**Non-`postgres` roles.** The Supabase grants include three owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements that only a member of `postgres` can run. When the connecting role is not a member (checked up front), the install still succeeds and is **complete**: the bundle and every plain `GRANT` are applied, covering all existing objects. The skipped statements are printed under "Optional SQL — requires postgres" purely as information — they only cover EQL objects `postgres` might later create outside stash tooling, and every `stash eql install`/`eql upgrade` re-grants all objects anyway (the generated Supabase migration embeds the grants too). Do not treat them as a required follow-up, and never work around a grants failure by disabling anything — the install itself is no longer rolled back by a grants failure (the bundle commits in its own transaction; grants run after it). | `--database-url ` | One-shot install (see below) | The removed `--eql-version`, `--latest`, `--drizzle`, `--migration`, `--direct`, `--migrations-dir`, and `--exclude-operator-family` options fail clearly instead of being ignored. A request for EQL v2 points dump-recovery users to the upstream EQL 2.3.1 SQL release. New installs are EQL v3 only; its pinned bundle self-adapts when a database role cannot create the optional operator family. @@ -692,7 +692,7 @@ type PreflightResult = { missing: string[] // blocking gaps, each naming what it blocks currentUser: string isSuperuser: boolean - memberOfPostgres: boolean | null // null: no postgres role exists; false never blocks + memberOfPostgres: boolean | null // null: no postgres role exists; false never blocks (it only skips optional statements) hasDatabaseCreate: boolean hasPublicCreate: boolean pgcryptoInstalled: boolean @@ -703,7 +703,8 @@ type PreflightResult = { type InstallResult = { // The skipped ALTER DEFAULT PRIVILEGES FOR ROLE postgres statements (with an // explanatory header) when the role is not a member of postgres; null when - // every grant ran. Surface it for the operator to apply. + // every grant ran. Optional SQL — surface as information, never as a + // required step: stash re-grants every object on each install/upgrade. deferredGrantsSql: string | null } ``` diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 18d14c1d4..c605a4b35 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -90,13 +90,16 @@ supabase db push # remote/linked project > without the Supabase CLI, where there is no migrations directory to write to. > **Connecting as a role that is not `postgres` (or a member of it)** — common -> on managed AI platforms such as Lovable — is fine: the install proceeds, and -> only the three owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` -> statements are skipped and printed at the end under "Deferred SQL — run as -> postgres". Apply them via the Supabase SQL editor or your platform's -> migration tool. Check ahead of time with `stash eql preflight` (`--json` for -> agents), which reports membership of `postgres` alongside the other role -> capabilities. +> on managed AI platforms such as Lovable — is fine: the install proceeds and +> is complete. Only the three owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE +> postgres` statements are skipped, and they are **optional**: they cover EQL +> objects `postgres` might later create outside stash tooling, and every +> `stash eql install`/`eql upgrade` re-grants all objects anyway. The CLI +> prints them as "Optional SQL — requires postgres" for operators who want +> them (Supabase SQL editor / migration tool); on platforms where nobody can +> act as `postgres`, nothing is lost. Check ahead of time with `stash eql +> preflight` (`--json` for agents), which reports membership of `postgres` +> alongside the other role capabilities. The generated file carries three things, in order: the EQL v3 bundle, the role grants, and the `cipherstash.cs_migrations` tracking schema that `stash From 54832b5ca42ffe6c6b1cbc0e7d2de767d5525855 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 18 Aug 2026 12:03:26 +1000 Subject: [PATCH 3/4] =?UTF-8?q?fix(cli):=20address=20review=20=E2=80=94=20?= =?UTF-8?q?guarded=20migration=20grants,=20hardened=20preflight,=20JSON=20?= =?UTF-8?q?contract,=20deprecated=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings from James, the Codex review, and /code-review, all fixed: - Generated Supabase migrations no longer die for non-postgres roles: the owner-scoped ALTER DEFAULT PRIVILEGES statements ship inside a pg_has_role-guarded DO block (SUPABASE_MIGRATION_GRANTS_SQL_V3), so a migration applied by Lovable's sandbox_exec skips them instead of rolling back the whole file. Live-proven in both membership arms. - PREFLIGHT_SQL guards has_schema_privilege against a dropped public schema (it raises 3F000, and the combined query took superusers down with it), reports pgcrypto's schema (outside extensions/public the bundle aborts — blocks even superusers), and reports drop-ownership of existing EQL schemas (reinstall opens with DROP SCHEMA ... CASCADE). Query-phase failures are now labelled as preflight failures, not connect failures. - eql install on an already-installed Supabase database re-applies the role grants (new applySupabaseGrants(), idempotent) instead of early-exiting, so a grants failure after the committed bundle heals on a plain re-run. - eql preflight --json keeps stdout pure JSON in every outcome: blockers are status 'blocked' (not 'ok'), and URL-resolution failures use the shared { status: 'error', code, message } envelope via a new jsonErrors resolver option. Human mode resolves the URL before any spinner exists (the interactive prompt was being redrawn over), and both modes warn when a config literal databaseUrl overrides --database-url, matching install. - No breaking change at 1.0: checkPermissions() stays as a deprecated adapter over preflight() and PermissionCheckResult is still exported, so the changeset remains minor. - Skills: the Non-postgres-roles paragraph no longer splits the eql install flags table (James's blocker); API/JSON-contract docs updated. README's EQLInstaller section rewritten for the current surface. --- .changeset/eql-preflight-deferred-grants.md | 6 +- packages/cli/README.md | 18 +- packages/cli/src/__tests__/installer.test.ts | 112 +++++++++++ .../commands/db/__tests__/preflight.test.ts | 36 ++++ packages/cli/src/commands/db/install.ts | 10 + packages/cli/src/commands/db/preflight.ts | 87 ++++++-- packages/cli/src/commands/eql/migration.ts | 19 +- packages/cli/src/config/database-url.ts | 26 ++- packages/cli/src/index.ts | 6 +- .../__tests__/guarded-grants.live.test.ts | 122 ++++++++++++ .../__tests__/preflight.live.test.ts | 13 ++ packages/cli/src/installer/grants.ts | 31 +++ packages/cli/src/installer/index.ts | 187 +++++++++++++++--- skills/stash-cli/SKILL.md | 15 +- 14 files changed, 623 insertions(+), 65 deletions(-) create mode 100644 packages/cli/src/installer/__tests__/guarded-grants.live.test.ts diff --git a/.changeset/eql-preflight-deferred-grants.md b/.changeset/eql-preflight-deferred-grants.md index c7a2a3836..62b1d20fe 100644 --- a/.changeset/eql-preflight-deferred-grants.md +++ b/.changeset/eql-preflight-deferred-grants.md @@ -5,6 +5,8 @@ EQL installs no longer abort on managed platforms whose database role is not `postgres`, and a new `stash eql preflight` command reports role capability before anything is attempted. - `stash eql install` (and `eql upgrade`) now run the EQL v3 bundle in its own transaction and the Supabase role grants after it commits, so a grants failure can no longer roll back a working install. When the connecting role is not a member of `postgres` (e.g. Lovable's `sandbox_exec`), the three owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements are skipped and the install completes without them — they are optional (they only cover EQL objects `postgres` might later create outside stash tooling, and stash re-grants every object on each install/upgrade); the SQL is printed as "Optional SQL — requires postgres" for operators who want it. Every plain `GRANT` still runs. Previously that single refused statement rolled back the entire install (~194 functions). -- New read-only `stash eql preflight` (`--json` for agents): reports `current_user`, superuser, membership of `postgres` (guarded for databases with no `postgres` role), `CREATE` on the database and on `public`, `pgcrypto`, and whether the EQL v3 schemas exist — each blocked row naming the statement it blocks. Exits 1 on blocking gaps; membership of `postgres` never blocks. The same check now runs at the head of `eql install`. +- Re-running `stash eql install` on an already-installed Supabase database now re-applies the role grants (idempotent) instead of exiting early, so an install whose grants step failed heals on a plain re-run. +- The migration generated by `stash eql migration --supabase` wraps the owner-scoped statements in a `pg_has_role` guard, so it applies cleanly whatever role the project's migration runner uses — a non-member role skips them instead of aborting the whole migration. +- New read-only `stash eql preflight` (`--json` for agents): reports `current_user`, superuser, membership of `postgres` (guarded for databases with no `postgres` role), `CREATE` on the database and on `public` (guarded for databases without a `public` schema), `pgcrypto` presence *and placement* (a pgcrypto outside `extensions`/`public` aborts the bundle, even for superusers), and the EQL v3 schemas' presence and drop-ownership (a reinstall begins with `DROP SCHEMA ... CASCADE`) — each blocked row naming the statement it blocks. Exits 1 on blocking gaps; membership of `postgres` never blocks. `--json` stdout is pure JSON in every outcome: `{ status: 'ok' | 'blocked', ... }`, or the shared `{ status: 'error', code, message }` envelope — including when no DATABASE_URL is configured. The same check runs at the head of `eql install`. - Install failure messages now state recoverability: a bundle failure says nothing was applied (rolled back); a grants failure says the install itself was kept. -- Library surface: `EQLInstaller.checkPermissions()` is replaced by `EQLInstaller.preflight()` (richer `PreflightResult`), and `install()` now returns `InstallResult` with the deferred SQL, if any. The exact `SUPABASE_PERMISSIONS_SQL_V3` block is unchanged byte-for-byte; new exports expose its immediate (`SUPABASE_IMMEDIATE_GRANTS_SQL_V3`) and owner-scoped (`SUPABASE_DEFAULT_PRIVILEGES_SQL_V3`) halves. +- Library surface: `EQLInstaller.preflight()` (rich `PreflightResult`) supersedes `checkPermissions()`, which remains as a deprecated adapter with its `PermissionCheckResult` shape unchanged — no breaking change for existing `stash@1.x` consumers. `install()` now returns `InstallResult` with the skipped SQL, if any, and `applySupabaseGrants()` re-applies the grants alone. The exact `SUPABASE_PERMISSIONS_SQL_V3` block is unchanged byte-for-byte; new exports expose its immediate (`SUPABASE_IMMEDIATE_GRANTS_SQL_V3`), owner-scoped (`SUPABASE_DEFAULT_PRIVILEGES_SQL_V3`), guarded (`SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3`), and migration (`SUPABASE_MIGRATION_GRANTS_SQL_V3`) forms. diff --git a/packages/cli/README.md b/packages/cli/README.md index 2f7ac85cf..019601d54 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -448,23 +448,29 @@ import { EQLInstaller } from 'stash' const installer = new EQLInstaller({ databaseUrl: process.env.DATABASE_URL! }) -const permissions = await installer.checkPermissions() -if (!permissions.ok) { - console.error('Missing permissions:', permissions.missing) +const preflight = await installer.preflight() +if (!preflight.ok) { + console.error('Blocking gaps:', preflight.missing) process.exit(1) } if (!(await installer.isInstalled())) { - await installer.install({ supabase: true }) + const { deferredGrantsSql } = await installer.install({ supabase: true }) + // Non-null when the connecting role is not a member of `postgres`: the + // optional owner-scoped default-privilege statements, skipped. The install + // is complete without them. + if (deferredGrantsSql) console.log(deferredGrantsSql) } ``` | Method | Returns | Description | |--------|---------|-------------| -| `checkPermissions()` | `Promise` | Check required database permissions | +| `preflight()` | `Promise` | Read-only role-capability report (superuser, membership of `postgres`, CREATE privileges, pgcrypto placement, EQL schema presence/ownership) | +| `checkPermissions()` | `Promise` | **Deprecated** — thin adapter over `preflight()`; will be removed in the next major | | `isInstalled()` | `Promise` | Check if the EQL v3 schemas exist | | `getInstalledVersion()` | `Promise` | Get the installed EQL version | -| `install(options?)` | `Promise` | Execute the EQL install SQL in a transaction | +| `install(options?)` | `Promise` | Install the EQL bundle (its own transaction), then the Supabase grants (outside it, so a grants failure keeps the install) | +| `applySupabaseGrants()` | `Promise` | Re-apply the Supabase role grants alone (idempotent) | Install options: `supabase`. diff --git a/packages/cli/src/__tests__/installer.test.ts b/packages/cli/src/__tests__/installer.test.ts index 6533f332a..4b4191226 100644 --- a/packages/cli/src/__tests__/installer.test.ts +++ b/packages/cli/src/__tests__/installer.test.ts @@ -22,8 +22,11 @@ const CAPABLE_ROW = { has_database_create: true, has_public_create: true, pgcrypto_installed: true, + pgcrypto_schema: 'extensions', eql_v3_present: false, eql_v3_internal_present: false, + can_drop_eql_v3: null, + can_drop_eql_v3_internal: null, } describe('EQLInstaller', () => { @@ -124,6 +127,85 @@ describe('EQLInstaller', () => { }) }) + it('blocks a relocated pgcrypto, even for a superuser', async () => { + mockConnect.mockResolvedValue(undefined) + mockQuery.mockResolvedValue({ + rows: [{ ...CAPABLE_ROW, pgcrypto_schema: 'crypto_home' }], + rowCount: 1, + }) + mockEnd.mockResolvedValue(undefined) + const { EQLInstaller } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) + + const result = await installer.preflight() + expect(result.ok).toBe(false) + expect(result.missing).toEqual([ + expect.stringContaining('pgcrypto relocated'), + ]) + expect(result.missing[0]).toContain('crypto_home') + expect(result.missing[0]).toContain('ALTER EXTENSION pgcrypto SET SCHEMA') + }) + + it('accepts pgcrypto in either supported schema', async () => { + mockConnect.mockResolvedValue(undefined) + mockEnd.mockResolvedValue(undefined) + const { EQLInstaller } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) + for (const schema of ['extensions', 'public']) { + mockQuery.mockResolvedValue({ + rows: [{ ...CAPABLE_ROW, pgcrypto_schema: schema }], + rowCount: 1, + }) + await expect(installer.preflight()).resolves.toMatchObject({ + ok: true, + pgcryptoSchema: schema, + }) + } + }) + + it('blocks a role that cannot drop an existing EQL schema', async () => { + mockConnect.mockResolvedValue(undefined) + mockQuery.mockResolvedValue({ + rows: [ + { + ...CAPABLE_ROW, + role_name: 'other_admin', + is_superuser: false, + member_of_postgres: false, + eql_v3_present: true, + eql_v3_internal_present: true, + can_drop_eql_v3: false, + can_drop_eql_v3_internal: false, + }, + ], + rowCount: 1, + }) + mockEnd.mockResolvedValue(undefined) + const { EQLInstaller } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) + + const result = await installer.preflight() + expect(result.ok).toBe(false) + expect(result.missing).toEqual([ + expect.stringContaining('ownership of the existing EQL schemas'), + ]) + expect(result.canDropEqlV3Schema).toBe(false) + }) + + it('keeps the deprecated checkPermissions() adapter shape', async () => { + mockConnect.mockResolvedValue(undefined) + mockQuery.mockResolvedValue({ rows: [CAPABLE_ROW], rowCount: 1 }) + mockEnd.mockResolvedValue(undefined) + const { EQLInstaller } = await import('@/installer/index.ts') + const installer = new EQLInstaller({ databaseUrl: 'postgres://test' }) + + await expect(installer.checkPermissions()).resolves.toEqual({ + ok: true, + missing: [], + isSuperuser: true, + }) + }) + it('requires both EQL v3 schemas for the current install', async () => { mockConnect.mockResolvedValue(undefined) mockEnd.mockResolvedValue(undefined) @@ -331,4 +413,34 @@ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA eql_v3_internal GRANT EXECU expect(line).toMatch(/^GRANT /) } }) + + it('guards every owner-scoped statement behind the membership check for migrations', async () => { + const { + SUPABASE_DEFAULT_PRIVILEGES_SQL_V3, + SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3, + SUPABASE_MIGRATION_GRANTS_SQL_V3, + SUPABASE_IMMEDIATE_GRANTS_SQL_V3, + } = await import('@/installer/grants.ts') + // Every owner-scoped statement appears inside the DO block. + for (const line of SUPABASE_DEFAULT_PRIVILEGES_SQL_V3.trim().split('\n')) { + expect(SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3).toContain(line) + } + expect(SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3).toContain( + "pg_has_role(current_user, 'postgres', 'MEMBER')", + ) + expect(SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3).toContain( + "EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres')", + ) + // The migration block = immediate grants + guarded owner-scoped block, + // with NO bare (unguarded) owner-scoped statement: every ALTER line must + // be indented inside the DO body. + expect(SUPABASE_MIGRATION_GRANTS_SQL_V3).toContain( + SUPABASE_IMMEDIATE_GRANTS_SQL_V3, + ) + for (const line of SUPABASE_MIGRATION_GRANTS_SQL_V3.split('\n')) { + if (line.includes('ALTER DEFAULT PRIVILEGES')) { + expect(line).toMatch(/^\s+ALTER DEFAULT PRIVILEGES/) + } + } + }) }) diff --git a/packages/cli/src/commands/db/__tests__/preflight.test.ts b/packages/cli/src/commands/db/__tests__/preflight.test.ts index 1b6330b7f..5e1eb23a1 100644 --- a/packages/cli/src/commands/db/__tests__/preflight.test.ts +++ b/packages/cli/src/commands/db/__tests__/preflight.test.ts @@ -9,8 +9,11 @@ const CAPABLE: PreflightResult = { hasDatabaseCreate: true, hasPublicCreate: true, pgcryptoInstalled: true, + pgcryptoSchema: 'extensions', eqlV3SchemaPresent: false, eqlV3InternalSchemaPresent: false, + canDropEqlV3Schema: null, + canDropEqlV3InternalSchema: null, missing: [], ok: true, } @@ -64,6 +67,39 @@ describe('renderPreflightReport', () => { expect(report).toContain('<- blocks: CREATE EXTENSION pgcrypto') }) + it('flags a relocated pgcrypto and shows its schema', () => { + const report = renderPreflightReport({ + ...CAPABLE, + pgcryptoSchema: 'crypto_home', + missing: ['x'], + ok: false, + }) + expect(report).toContain('present (in crypto_home)') + expect(report).toContain('<- blocks: not on the EQL search_path') + }) + + it('adds the drop-ownership row only when an EQL schema exists', () => { + expect(renderPreflightReport(CAPABLE)).not.toContain('can drop EQL schemas') + const blocked = renderPreflightReport({ + ...CAPABLE, + eqlV3SchemaPresent: true, + eqlV3InternalSchemaPresent: true, + canDropEqlV3Schema: false, + canDropEqlV3InternalSchema: false, + missing: ['x'], + ok: false, + }) + expect(blocked).toContain('can drop EQL schemas no') + expect(blocked).toContain('<- blocks: reinstall') + const fine = renderPreflightReport({ + ...CAPABLE, + eqlV3SchemaPresent: true, + canDropEqlV3Schema: true, + canDropEqlV3InternalSchema: true, + }) + expect(fine).toContain('can drop EQL schemas yes') + }) + it('suppresses privilege annotations for a superuser', () => { const report = renderPreflightReport({ ...CAPABLE, diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index f53e06102..7b5fed75f 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -164,6 +164,16 @@ export async function installCommand( const installed = await installer.isInstalled() s.stop(installed ? 'EQL is already installed.' : 'EQL is not installed.') if (installed) { + // Re-apply the grants even when the bundle is present: since the bundle + // commits before the grants run, a grants failure leaves an installed- + // but-ungranted database, and a plain re-run must heal it rather than + // early-exit past it. Idempotent, a handful of statements. + if (supabase) { + s.start('Re-applying Supabase role grants...') + const grantsResult = await installer.applySupabaseGrants() + s.stop('Supabase role grants applied.') + reportSupabaseGrantsOutcome(grantsResult) + } p.log.info('Use --force to re-run the install script.') p.outro('Nothing to do.') return 'already-installed' diff --git a/packages/cli/src/commands/db/preflight.ts b/packages/cli/src/commands/db/preflight.ts index 9fbd8ec34..a98fa9a79 100644 --- a/packages/cli/src/commands/db/preflight.ts +++ b/packages/cli/src/commands/db/preflight.ts @@ -1,4 +1,5 @@ import * as p from '@clack/prompts' +import { emitJsonError, emitJsonEvent } from '@/commands/auth/events.js' import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' import { resolveDatabaseUrl } from '@/config/database-url.js' import { findConfigFile, loadStashConfig } from '@/config/index.js' @@ -7,17 +8,43 @@ import { EQLInstaller, type PreflightResult } from '@/installer/index.js' /** * Preflight runs BEFORE anything is set up, so a missing stash.config.ts must * not fail it — fall back to the plain DATABASE_URL resolution chain the - * installer itself uses when no config exists yet. + * installer itself uses when no config exists yet. In `json` mode the + * resolver keeps stdout parseable: informational chrome and the interactive + * prompt are suppressed (`quiet`) and failures come out as the shared + * `{ status: 'error', code, message }` envelope (`jsonErrors`). + * + * Mirrors `installCommand`'s precedence caveat: a hand-set literal + * `databaseUrl` in stash.config.ts beats `--database-url`. That is surprising + * enough to say out loud — to stderr in json mode, so stdout stays JSON. */ async function resolvePreflightDatabaseUrl( databaseUrlFlag: string | undefined, - quiet: boolean, + json: boolean, ): Promise { - if (findConfigFile(process.cwd())) { - const config = await loadStashConfig({ databaseUrlFlag, quiet }) + const configPath = findConfigFile(process.cwd()) + if (configPath) { + const config = await loadStashConfig( + { databaseUrlFlag, quiet: json, jsonErrors: json }, + configPath, + ) + if ( + databaseUrlFlag !== undefined && + config.databaseUrl !== databaseUrlFlag.trim() + ) { + const warning = `Ignoring --database-url: ${configPath} sets an explicit databaseUrl that takes precedence. Probing the config's database.` + if (json) { + process.stderr.write(`${warning}\n`) + } else { + p.log.warn(warning) + } + } return config.databaseUrl } - return resolveDatabaseUrl({ databaseUrlFlag, quiet }) + return resolveDatabaseUrl({ + databaseUrlFlag, + quiet: json, + jsonErrors: json, + }) } /** @@ -28,7 +55,7 @@ async function resolvePreflightDatabaseUrl( * * Exit code: 1 when a blocking gap is present (`result.ok === false`), else 0. * Membership of `postgres` is reported but never blocks — the installer - * defers the owner-scoped Supabase grants for non-member roles. + * skips the optional owner-scoped Supabase grants for non-member roles. */ export async function preflightCommand( options: { databaseUrl?: string; json?: boolean } = {}, @@ -44,24 +71,26 @@ export async function preflightCommand( result = await installer.preflight() } catch (error) { const message = error instanceof Error ? error.message : String(error) - console.log(JSON.stringify({ status: 'error', message })) + emitJsonError('preflight_failed', message) process.exit(1) } - console.log(JSON.stringify({ status: 'ok', ...result })) + // `status` is the discriminator agents gate on, so blockers must not + // masquerade as 'ok' — 'blocked' means the probe worked and found gaps. + emitJsonEvent({ status: result.ok ? 'ok' : 'blocked', ...result }) if (!result.ok) process.exit(1) return } p.intro(runnerCommand(detectPackageManager(), 'stash eql preflight')) - const s = p.spinner() - s.start('Resolving database URL...') + // Resolve the URL before any spinner exists: tier 4 of the resolver is an + // interactive prompt, and a live spinner would redraw over it. const databaseUrl = await resolvePreflightDatabaseUrl( options.databaseUrl, false, ) - s.stop('Database URL resolved.') + const s = p.spinner() s.start('Probing database role capability...') const installer = new EQLInstaller({ databaseUrl }) let result: PreflightResult @@ -97,6 +126,13 @@ export async function preflightCommand( /** The human-readable rows, aligned. Exported for unit tests. */ export function renderPreflightReport(result: PreflightResult): string { const yesNo = (value: boolean) => (value ? 'yes' : 'no') + const pgcryptoValue = result.pgcryptoInstalled + ? `present${result.pgcryptoSchema ? ` (in ${result.pgcryptoSchema})` : ''}` + : 'absent' + const pgcryptoUnsupported = + result.pgcryptoInstalled && + result.pgcryptoSchema !== null && + !['extensions', 'public'].includes(result.pgcryptoSchema) const rows: Array<[string, string, string?]> = [ ['current_user', result.currentUser], ['superuser', yesNo(result.isSuperuser)], @@ -125,10 +161,14 @@ export function renderPreflightReport(result: PreflightResult): string { ], [ 'pgcrypto', - result.pgcryptoInstalled ? 'present' : 'absent', - result.pgcryptoInstalled || result.hasDatabaseCreate || result.isSuperuser - ? undefined - : '<- blocks: CREATE EXTENSION pgcrypto', + pgcryptoValue, + pgcryptoUnsupported + ? '<- blocks: not on the EQL search_path (ALTER EXTENSION pgcrypto SET SCHEMA extensions)' + : !result.pgcryptoInstalled && + !result.hasDatabaseCreate && + !result.isSuperuser + ? '<- blocks: CREATE EXTENSION pgcrypto' + : undefined, ], ['eql_v3 schema', result.eqlV3SchemaPresent ? 'present' : 'absent'], [ @@ -136,6 +176,23 @@ export function renderPreflightReport(result: PreflightResult): string { result.eqlV3InternalSchemaPresent ? 'present' : 'absent', ], ] + // Ownership only matters (and is only known) when a schema already exists: + // a reinstall opens with DROP SCHEMA ... CASCADE. + if ( + result.canDropEqlV3Schema !== null || + result.canDropEqlV3InternalSchema !== null + ) { + const canDrop = + result.canDropEqlV3Schema !== false && + result.canDropEqlV3InternalSchema !== false + rows.push([ + 'can drop EQL schemas', + yesNo(canDrop), + canDrop + ? undefined + : '<- blocks: reinstall (DROP SCHEMA ... CASCADE needs the owner or a superuser)', + ]) + } const labelWidth = Math.max(...rows.map(([label]) => label.length)) return rows .map(([label, value, annotation]) => diff --git a/packages/cli/src/commands/eql/migration.ts b/packages/cli/src/commands/eql/migration.ts index 4fa1dbb3c..efae6d2c7 100644 --- a/packages/cli/src/commands/eql/migration.ts +++ b/packages/cli/src/commands/eql/migration.ts @@ -22,7 +22,10 @@ import { execArgv, execCommand, } from '@/commands/init/utils.js' -import { loadBundledEqlSql, supabaseGrantsFor } from '@/installer/index.js' +import { + loadBundledEqlSql, + SUPABASE_MIGRATION_GRANTS_SQL_V3, +} from '@/installer/index.js' import { messages } from '@/messages.js' const DEFAULT_MIGRATION_NAME = 'install-eql' @@ -117,9 +120,15 @@ export interface EqlMigrationOptions { * One source of truth: the SQL is the CLI's bundled v3 install script * (`loadBundledEqlSql()`) — the same bundle `stash eql install` * applies directly. On `--supabase` the v3 role grants are appended - * (`supabaseGrantsFor()` → USAGE/EXECUTE on `eql_v3` + `eql_v3_internal` for - * `anon`/`authenticated`/`service_role`), matching `stash eql install --supabase`. - * Apps that connect directly as `postgres` don't need the grants, but they're + * (`SUPABASE_MIGRATION_GRANTS_SQL_V3` → USAGE/EXECUTE on `eql_v3` + + * `eql_v3_internal` for `anon`/`authenticated`/`service_role`), matching + * `stash eql install --supabase`. The owner-scoped `ALTER DEFAULT PRIVILEGES + * FOR ROLE postgres` statements ship inside a membership guard: a migration + * runs as whatever role the project's runner uses, and on platforms where + * that role is not a member of `postgres` (Lovable's `sandbox_exec`) the + * unguarded form would abort the migration and roll back the whole file — + * the exact failure `stash eql install` avoids by skipping them. Apps that + * connect directly as `postgres` don't need the grants, but they're * idempotent and harmless, and required when the same tables are reached via * PostgREST/RLS. * @@ -131,7 +140,7 @@ export interface EqlMigrationOptions { export function buildEqlV3MigrationSql(opts: { supabase: boolean }): string { const eqlSql = loadBundledEqlSql() const grants = opts.supabase - ? `\n\n-- Supabase role grants: let anon/authenticated/service_role use the\n-- eql_v3 + eql_v3_internal schemas (required when tables are reached via\n-- PostgREST/RLS; harmless otherwise).\n${supabaseGrantsFor().trim()}` + ? `\n\n-- Supabase role grants: let anon/authenticated/service_role use the\n-- eql_v3 + eql_v3_internal schemas (required when tables are reached via\n-- PostgREST/RLS; harmless otherwise).\n${SUPABASE_MIGRATION_GRANTS_SQL_V3.trim()}` : '' return `${eqlSql.trim()}${grants}\n\n-- CipherStash encryption-migration tracking schema.\n-- Tracks per-column phase + backfill progress for \`stash encrypt\`.\n${MIGRATIONS_SCHEMA_SQL.trim()}\n` } diff --git a/packages/cli/src/config/database-url.ts b/packages/cli/src/config/database-url.ts index cb10acdec..5d5b91a18 100644 --- a/packages/cli/src/config/database-url.ts +++ b/packages/cli/src/config/database-url.ts @@ -24,6 +24,7 @@ import { execSync } from 'node:child_process' import { existsSync } from 'node:fs' import { join } from 'node:path' import * as p from '@clack/prompts' +import { emitJsonError } from '../commands/auth/events.js' import { detectSupabaseProject } from '../commands/db/detect.js' import { detectPackageManager, runnerCommand } from '../commands/init/utils.js' import { messages } from '../messages.js' @@ -43,6 +44,14 @@ export interface ResolveDatabaseUrlOptions { * Error paths still print (and exit 1) — a failure should stay diagnosable. */ quiet?: boolean + /** + * Surface resolution failures as the shared `{ status: 'error', code, + * message }` NDJSON envelope instead of clack chrome, keeping a `--json` + * command's stdout parseable on its most likely first-run failure (no + * DATABASE_URL configured). Exit code stays 1. Implies nothing about + * `quiet` — pass both for a JSON command. + */ + jsonErrors?: boolean } // The CLI ships as two tsup bundles (`dist/index.js` for the library and @@ -192,7 +201,11 @@ export async function resolveDatabaseUrl( if (ctx.databaseUrlFlag !== undefined) { const trimmed = ctx.databaseUrlFlag.trim() if (!trimmed || !isUrlParseable(trimmed)) { - p.log.error(messages.db.urlFlagMalformed) + if (ctx.jsonErrors) { + emitJsonError('database_url_invalid', messages.db.urlFlagMalformed) + } else { + p.log.error(messages.db.urlFlagMalformed) + } process.exit(1) } if (!ctx.quiet) p.log.info(messages.db.urlResolvedFromFlag) @@ -231,8 +244,13 @@ export async function resolveDatabaseUrl( } // 5. Hard fail. - p.log.error( - isCi ? messages.db.urlMissingCi : messages.db.urlMissingInteractive, - ) + const missingMessage = isCi + ? messages.db.urlMissingCi + : messages.db.urlMissingInteractive + if (ctx.jsonErrors) { + emitJsonError('database_url_missing', missingMessage) + } else { + p.log.error(missingMessage) + } process.exit(1) } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 812c41e94..f36bf596f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -5,5 +5,9 @@ export type { ResolveDatabaseUrlOptions } from './config/database-url.ts' export { resolveDatabaseUrl } from './config/database-url.ts' export type { StashConfig } from './config/index.ts' export { defineConfig, loadStashConfig } from './config/index.ts' -export type { InstallResult, PreflightResult } from './installer/index.ts' +export type { + InstallResult, + PermissionCheckResult, + PreflightResult, +} from './installer/index.ts' export { EQLInstaller, loadBundledEqlSql } from './installer/index.ts' diff --git a/packages/cli/src/installer/__tests__/guarded-grants.live.test.ts b/packages/cli/src/installer/__tests__/guarded-grants.live.test.ts new file mode 100644 index 000000000..33da94079 --- /dev/null +++ b/packages/cli/src/installer/__tests__/guarded-grants.live.test.ts @@ -0,0 +1,122 @@ +/** + * Live proof that `SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3` — the + * owner-scoped grants as shipped inside generated migration files — is valid + * plpgsql and does what the guard promises in BOTH membership arms: + * + * - run by a role that is NOT a member of `postgres`, it succeeds silently + * (the unguarded form fails with `permission denied to change default + * privileges` and, in a migration, rolls back the whole file — the exact + * Lovable failure); + * - run by a member, it actually records the default-privilege rules. + * + * A unit test cannot see either: `ALTER DEFAULT PRIVILEGES` inside a DO block + * only proves itself against a real server. + * + * Gated on STASH_TEST_DATABASE_URL like the other live suites. The test is + * self-sufficient on a fresh database: it creates the Supabase roles, the two + * EQL schema names (bare — grants only need them to exist), and its own + * postgres/member/outsider roles, and reverses the default-privilege rules it + * created. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3 } from '../grants.js' + +const DATABASE_URL = process.env.STASH_TEST_DATABASE_URL +const describeLive = DATABASE_URL ? describe : describe.skip + +const OUTSIDER = `stash_guard_outsider_${process.pid}` +const MEMBER = `stash_guard_member_${process.pid}` +const PASSWORD = 'stash-guard-test' + +let createdPostgresRole = false + +async function query(sql: string, url?: string): Promise { + const { default: pg } = await import('pg') + const client = new pg.Client({ connectionString: url ?? DATABASE_URL }) + await client.connect() + try { + const result = await client.query(sql) + return result.rows as T[] + } finally { + await client.end().catch(() => undefined) + } +} + +function urlAs(user: string): string { + const url = new URL(DATABASE_URL as string) + url.username = user + url.password = PASSWORD + return url.toString() +} + +async function defaultAclCount(): Promise { + const rows = await query<{ n: number }>( + `SELECT count(*)::int AS n FROM pg_default_acl d + JOIN pg_namespace n ON n.oid = d.defaclnamespace + JOIN pg_roles r ON r.oid = d.defaclrole + WHERE r.rolname = 'postgres' AND n.nspname IN ('eql_v3', 'eql_v3_internal')`, + ) + return rows[0]?.n ?? 0 +} + +describeLive('guarded owner-scoped grants — live Postgres', () => { + beforeAll(async () => { + for (const role of ['anon', 'authenticated', 'service_role']) { + await query( + `DO $$ BEGIN CREATE ROLE ${role}; EXCEPTION WHEN duplicate_object THEN NULL; END $$`, + ) + } + await query('CREATE SCHEMA IF NOT EXISTS eql_v3') + await query('CREATE SCHEMA IF NOT EXISTS eql_v3_internal') + const rows = await query<{ n: number }>( + "SELECT count(*)::int AS n FROM pg_roles WHERE rolname = 'postgres'", + ) + if (rows[0]?.n === 0) { + await query('CREATE ROLE postgres') + createdPostgresRole = true + } + await query(`CREATE ROLE ${OUTSIDER} LOGIN PASSWORD '${PASSWORD}'`) + await query(`CREATE ROLE ${MEMBER} LOGIN PASSWORD '${PASSWORD}'`) + await query(`GRANT postgres TO ${MEMBER}`) + // The member must be able to run the DO block's ALTER statements, which + // act on role `postgres` in these schemas — schema USAGE is enough for + // the block itself to execute. + await query( + `GRANT USAGE, CREATE ON SCHEMA eql_v3, eql_v3_internal TO ${OUTSIDER}, ${MEMBER}`, + ) + }) + + afterAll(async () => { + // Reverse whatever the member arm recorded, then the fixture roles. + await query( + `ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA eql_v3 REVOKE SELECT ON TABLES FROM anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA eql_v3 REVOKE EXECUTE ON ROUTINES FROM anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA eql_v3 REVOKE USAGE ON SEQUENCES FROM anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA eql_v3_internal REVOKE EXECUTE ON ROUTINES FROM anon, authenticated, service_role;`, + ).catch(() => undefined) + await query( + `REVOKE USAGE, CREATE ON SCHEMA eql_v3, eql_v3_internal FROM ${OUTSIDER}, ${MEMBER}`, + ).catch(() => undefined) + await query(`DROP ROLE IF EXISTS ${OUTSIDER}`).catch(() => undefined) + await query(`DROP ROLE IF EXISTS ${MEMBER}`).catch(() => undefined) + if (createdPostgresRole) { + await query('DROP ROLE IF EXISTS postgres').catch(() => undefined) + } + }) + + it('succeeds silently for a role that is not a member of postgres', async () => { + const before = await defaultAclCount() + await expect( + query(SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3, urlAs(OUTSIDER)), + ).resolves.toBeDefined() + // Guard skipped the statements: nothing recorded. + expect(await defaultAclCount()).toBe(before) + }) + + it('records the default-privilege rules when run by a member of postgres', async () => { + const before = await defaultAclCount() + await query(SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3, urlAs(MEMBER)) + expect(await defaultAclCount()).toBeGreaterThan(before) + }) +}) diff --git a/packages/cli/src/installer/__tests__/preflight.live.test.ts b/packages/cli/src/installer/__tests__/preflight.live.test.ts index 80132d168..88b724f3c 100644 --- a/packages/cli/src/installer/__tests__/preflight.live.test.ts +++ b/packages/cli/src/installer/__tests__/preflight.live.test.ts @@ -74,6 +74,19 @@ describeLive('EQLInstaller.preflight — live Postgres', () => { expect(result.currentUser).toBe('cipherstash') expect(result.isSuperuser).toBe(true) expect(result.ok).toBe(true) + // The pgcrypto-placement and schema-ownership probes must be answers, not + // query failures, whatever state the shared database is in. + if (result.pgcryptoInstalled) { + expect(typeof result.pgcryptoSchema).toBe('string') + } else { + expect(result.pgcryptoSchema).toBeNull() + } + if (result.eqlV3SchemaPresent) { + // A superuser can always drop. + expect(result.canDropEqlV3Schema).toBe(true) + } else { + expect(result.canDropEqlV3Schema).toBeNull() + } if (await postgresRoleExists()) { // Image variant that ships a postgres role: a superuser is a member. expect(result.memberOfPostgres).toBe(true) diff --git a/packages/cli/src/installer/grants.ts b/packages/cli/src/installer/grants.ts index be044f3c7..352b57474 100644 --- a/packages/cli/src/installer/grants.ts +++ b/packages/cli/src/installer/grants.ts @@ -158,3 +158,34 @@ export const DEFERRED_GRANTS_HEADER = `-- Optional: the statements below require -- (stash re-grants every object on each install/upgrade). If you want them, -- apply them via your platform's migration tool or the Supabase SQL editor. ` + +/** + * The owner-scoped statements wrapped in a membership guard, for SQL that + * ships in a generated migration file. A migration runs as whatever role the + * project's migration runner uses — on Lovable that is `sandbox_exec`, and a + * bare `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` would fail there and roll + * back the entire migration (bundle included). The guard makes the file safe + * for every role: members apply the statements, non-members skip them — + * losing only the optional future-object rules, exactly as `stash eql + * install` behaves. + */ +export const SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3 = `DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres') + AND pg_has_role(current_user, 'postgres', 'MEMBER') THEN + ${SUPABASE_DEFAULT_PRIVILEGES_SQL_V3.trim().split('\n').join('\n ')} + END IF; +END $$; +` + +/** + * The grants block for generated migration files: the plain `GRANT`s + * verbatim, then the owner-scoped statements behind the membership guard. + * `SUPABASE_PERMISSIONS_SQL_V3` (the direct-install block) is deliberately + * NOT reused here — its unguarded owner-scoped statements would abort a + * migration applied by a non-member role. + */ +export const SUPABASE_MIGRATION_GRANTS_SQL_V3 = `${SUPABASE_IMMEDIATE_GRANTS_SQL_V3}-- Optional owner-scoped default privileges: applied only when the migration +-- runs as a member of \`postgres\` (they cover EQL objects \`postgres\` might +-- later create outside stash tooling; stash re-grants on every install). +${SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3}` diff --git a/packages/cli/src/installer/index.ts b/packages/cli/src/installer/index.ts index 62c791080..4b41fd33c 100644 --- a/packages/cli/src/installer/index.ts +++ b/packages/cli/src/installer/index.ts @@ -14,7 +14,9 @@ export { EQL_V3_INTERNAL_SCHEMA_NAME, EQL_V3_SCHEMA_NAME, SUPABASE_DEFAULT_PRIVILEGES_SQL_V3, + SUPABASE_GUARDED_DEFAULT_PRIVILEGES_SQL_V3, SUPABASE_IMMEDIATE_GRANTS_SQL_V3, + SUPABASE_MIGRATION_GRANTS_SQL_V3, SUPABASE_PERMISSIONS_SQL_V3, supabaseInternalPermissionsSql, supabasePermissionsSql, @@ -62,12 +64,38 @@ export interface PreflightResult { hasDatabaseCreate: boolean hasPublicCreate: boolean pgcryptoInstalled: boolean + /** + * The schema `pgcrypto` lives in, or `null` when not installed. The pinned + * bundle accepts `extensions` and `public` (its functions' search_path) and + * ABORTS for any other schema — so an unsupported placement blocks even a + * superuser. + */ + pgcryptoSchema: string | null eqlV3SchemaPresent: boolean eqlV3InternalSchemaPresent: boolean + /** + * Whether `current_user` may drop the existing `eql_v3` / `eql_v3_internal` + * schemas (owner, member of the owning role, or superuser). `null` when the + * schema is absent. Matters because a reinstall begins with + * `DROP SCHEMA ... CASCADE`. + */ + canDropEqlV3Schema: boolean | null + canDropEqlV3InternalSchema: boolean | null missing: string[] ok: boolean } +/** + * The legacy permission-check shape. + * @deprecated Use {@link EQLInstaller.preflight} and {@link PreflightResult}; + * this remains only so existing `stash@1.x` consumers keep compiling. + */ +export interface PermissionCheckResult { + ok: boolean + missing: string[] + isSuperuser: boolean +} + /** What `install()` actually did, beyond succeeding. */ export interface InstallResult { /** @@ -83,9 +111,14 @@ export interface InstallResult { } /** - * One query answering every preflight question. `pg_has_role` is guarded by - * the `CASE`: it raises on a nonexistent role name, and not every database - * has a `postgres` role. + * One query answering every preflight question. Two guard patterns are + * load-bearing: `pg_has_role` raises on a nonexistent role name (not every + * database has a `postgres` role), and `has_schema_privilege` raises 3F000 on + * a nonexistent schema (hardened databases drop `public`) — each probe that + * can raise is wrapped so a missing object reads as a capability answer, not + * a query failure. The scalar subqueries against `pg_namespace` return NULL + * (not an error) when the schema is absent, which maps to the `null` arms of + * {@link PreflightResult}. */ const PREFLIGHT_SQL = ` SELECT @@ -95,12 +128,24 @@ const PREFLIGHT_SQL = ` THEN pg_has_role(current_user, 'postgres', 'MEMBER') END AS member_of_postgres, has_database_privilege(current_user, current_database(), 'CREATE') AS has_database_create, - has_schema_privilege(current_user, 'public', 'CREATE') AS has_public_create, + CASE WHEN EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'public') + THEN has_schema_privilege(current_user, 'public', 'CREATE') + ELSE false + END AS has_public_create, EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto') AS pgcrypto_installed, + (SELECT n.nspname FROM pg_extension e JOIN pg_namespace n ON n.oid = e.extnamespace + WHERE e.extname = 'pgcrypto') AS pgcrypto_schema, EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = '${EQL_V3_SCHEMA_NAME}') AS eql_v3_present, - EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = '${EQL_V3_INTERNAL_SCHEMA_NAME}') AS eql_v3_internal_present + EXISTS (SELECT 1 FROM information_schema.schemata WHERE schema_name = '${EQL_V3_INTERNAL_SCHEMA_NAME}') AS eql_v3_internal_present, + (SELECT pg_has_role(current_user, n.nspowner, 'MEMBER') FROM pg_namespace n + WHERE n.nspname = '${EQL_V3_SCHEMA_NAME}') AS can_drop_eql_v3, + (SELECT pg_has_role(current_user, n.nspowner, 'MEMBER') FROM pg_namespace n + WHERE n.nspname = '${EQL_V3_INTERNAL_SCHEMA_NAME}') AS can_drop_eql_v3_internal ` +/** The schemas the pinned bundle accepts `pgcrypto` in (its search_path). */ +const SUPPORTED_PGCRYPTO_SCHEMAS = ['extensions', 'public'] + export class EQLInstaller { private readonly databaseUrl: string @@ -112,10 +157,27 @@ export class EQLInstaller { const client = new pg.Client({ connectionString: this.databaseUrl }) try { await client.connect() + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + await client.end().catch(() => {}) + throw new Error(`Failed to connect to database: ${detail}`, { + cause: error, + }) + } + try { const result = await client.query(PREFLIGHT_SQL) const row = result.rows[0] ?? {} const isSuperuser = row.is_superuser === true const hasDatabaseCreate = row.has_database_create === true + const pgcryptoInstalled = row.pgcrypto_installed === true + const pgcryptoSchema = + typeof row.pgcrypto_schema === 'string' ? row.pgcrypto_schema : null + const asBoolOrNull = (value: unknown) => + typeof value === 'boolean' ? value : null + const canDropEqlV3Schema = asBoolOrNull(row.can_drop_eql_v3) + const canDropEqlV3InternalSchema = asBoolOrNull( + row.can_drop_eql_v3_internal, + ) const missing: string[] = [] if (!isSuperuser) { if (!hasDatabaseCreate) { @@ -128,30 +190,52 @@ export class EQLInstaller { 'CREATE on public schema (required for CREATE DOMAIN public.eql_v3_*)', ) } - if (row.pgcrypto_installed !== true && !hasDatabaseCreate) { + if (!pgcryptoInstalled && !hasDatabaseCreate) { missing.push( 'SUPERUSER or extension owner (required for CREATE EXTENSION pgcrypto)', ) } } + // Not gated on superuser: the bundle itself raises for a pgcrypto + // outside its functions' search_path, whoever runs it. + if ( + pgcryptoInstalled && + pgcryptoSchema !== null && + !SUPPORTED_PGCRYPTO_SCHEMAS.includes(pgcryptoSchema) + ) { + missing.push( + `pgcrypto relocated (it is in schema "${pgcryptoSchema}", which is not on the EQL search_path — the install aborts; fix with: ALTER EXTENSION pgcrypto SET SCHEMA extensions)`, + ) + } + // pg_has_role is true for superusers and for the owner, so this only + // fires for a role that genuinely cannot run the bundle's opening + // DROP SCHEMA ... CASCADE against someone else's install. + if ( + canDropEqlV3Schema === false || + canDropEqlV3InternalSchema === false + ) { + missing.push( + 'ownership of the existing EQL schemas (a reinstall begins with DROP SCHEMA eql_v3 / eql_v3_internal CASCADE, which needs the owner, a member of the owning role, or a superuser)', + ) + } return { currentUser: String(row.role_name ?? 'unknown'), isSuperuser, - memberOfPostgres: - typeof row.member_of_postgres === 'boolean' - ? row.member_of_postgres - : null, + memberOfPostgres: asBoolOrNull(row.member_of_postgres), hasDatabaseCreate, hasPublicCreate: row.has_public_create === true, - pgcryptoInstalled: row.pgcrypto_installed === true, + pgcryptoInstalled, + pgcryptoSchema, eqlV3SchemaPresent: row.eql_v3_present === true, eqlV3InternalSchemaPresent: row.eql_v3_internal_present === true, + canDropEqlV3Schema, + canDropEqlV3InternalSchema, missing, ok: missing.length === 0, } } catch (error) { const detail = error instanceof Error ? error.message : String(error) - throw new Error(`Failed to connect to database: ${detail}`, { + throw new Error(`Database preflight query failed: ${detail}`, { cause: error, }) } finally { @@ -159,6 +243,21 @@ export class EQLInstaller { } } + /** + * The legacy permission check. + * @deprecated Use {@link preflight}; this thin adapter exists only so + * existing `stash@1.x` consumers keep working, and will be removed in the + * next major. + */ + async checkPermissions(): Promise { + const result = await this.preflight() + return { + ok: result.ok, + missing: result.missing, + isSuperuser: result.isSuperuser, + } + } + /** Generation-aware read-only detection retained for legacy diagnostics. */ async isInstalled(options?: { eqlVersion?: EqlVersion }): Promise { const client = new pg.Client({ connectionString: this.databaseUrl }) @@ -262,24 +361,11 @@ export class EQLInstaller { if (!options?.supabase) return { deferredGrantsSql: null } try { - const memberResult = await client.query(` - SELECT CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres') - THEN pg_has_role(current_user, 'postgres', 'MEMBER') - END AS member_of_postgres - `) - if (memberResult.rows[0]?.member_of_postgres === true) { - await client.query(SUPABASE_PERMISSIONS_SQL_V3) - return { deferredGrantsSql: null } - } - await client.query(SUPABASE_IMMEDIATE_GRANTS_SQL_V3) - return { - deferredGrantsSql: - DEFERRED_GRANTS_HEADER + SUPABASE_DEFAULT_PRIVILEGES_SQL_V3, - } + return await this.runSupabaseGrants(client) } catch (error) { const detail = error instanceof Error ? error.message : String(error) throw new Error( - `EQL v3 is installed, but granting the Supabase roles failed: ${detail}. The install itself was NOT rolled back — apply the grants from \`stash eql migration --supabase\`, or re-run \`stash eql install --force\`.`, + `EQL v3 is installed, but granting the Supabase roles failed: ${detail}. The install itself was NOT rolled back — re-run \`stash eql install --force\` (or plain \`stash eql install\`, which re-applies the grants on an already-installed database).`, { cause: error }, ) } @@ -287,4 +373,51 @@ export class EQLInstaller { await client.end() } } + + /** + * Re-apply the Supabase role grants on their own — idempotent, safe to run + * any number of times. This is how a grants failure after a committed + * install heals: `stash eql install` calls it on the already-installed + * path, so a plain re-run recovers without `--force`. + */ + async applySupabaseGrants(): Promise { + const client = new pg.Client({ connectionString: this.databaseUrl }) + try { + await client.connect() + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + await client.end().catch(() => {}) + throw new Error(`Failed to connect to database: ${detail}`, { + cause: error, + }) + } + try { + return await this.runSupabaseGrants(client) + } catch (error) { + const detail = error instanceof Error ? error.message : String(error) + throw new Error(`Failed to apply the Supabase role grants: ${detail}`, { + cause: error, + }) + } finally { + await client.end() + } + } + + /** The shared grants phase: full block for members, immediate half + deferred tail otherwise. */ + private async runSupabaseGrants(client: pg.Client): Promise { + const memberResult = await client.query(` + SELECT CASE WHEN EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres') + THEN pg_has_role(current_user, 'postgres', 'MEMBER') + END AS member_of_postgres + `) + if (memberResult.rows[0]?.member_of_postgres === true) { + await client.query(SUPABASE_PERMISSIONS_SQL_V3) + return { deferredGrantsSql: null } + } + await client.query(SUPABASE_IMMEDIATE_GRANTS_SQL_V3) + return { + deferredGrantsSql: + DEFERRED_GRANTS_HEADER + SUPABASE_DEFAULT_PRIVILEGES_SQL_V3, + } + } } diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index a40065441..5953fec6e 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -358,8 +358,8 @@ Membership of `postgres` is reported but never blocks: `eql install` handles a n | Flag | Description | |---|---| -| `--json` | Machine-readable result instead of the table | -| `--database-url ` | Probe that database (no config needed) | +| `--json` | Machine-readable result instead of the table. Stdout is pure JSON even on failure: success is `{ status: 'ok', ... }`, blockers are `{ status: 'blocked', ... }` (exit 1), and failures — including a missing/malformed DATABASE_URL — are the shared `{ status: 'error', code, message }` envelope | +| `--database-url ` | Probe that database (no config needed). A hand-set literal `databaseUrl` in stash.config.ts still wins, with a warning (stderr in `--json` mode) | #### `eql install` @@ -372,10 +372,10 @@ Gets a project from zero to a direct EQL v3 install. It loads an existing `stash | `--force` | Reinstall even if EQL is present | | `--dry-run` | Show what would happen | | `--supabase` | Supabase-compatible install; grants `anon`, `authenticated`, and `service_role` | - -**Non-`postgres` roles.** The Supabase grants include three owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements that only a member of `postgres` can run. When the connecting role is not a member (checked up front), the install still succeeds and is **complete**: the bundle and every plain `GRANT` are applied, covering all existing objects. The skipped statements are printed under "Optional SQL — requires postgres" purely as information — they only cover EQL objects `postgres` might later create outside stash tooling, and every `stash eql install`/`eql upgrade` re-grants all objects anyway (the generated Supabase migration embeds the grants too). Do not treat them as a required follow-up, and never work around a grants failure by disabling anything — the install itself is no longer rolled back by a grants failure (the bundle commits in its own transaction; grants run after it). | `--database-url ` | One-shot install (see below) | +**Non-`postgres` roles.** The Supabase grants include three owner-scoped `ALTER DEFAULT PRIVILEGES FOR ROLE postgres` statements that only a member of `postgres` can run. When the connecting role is not a member (checked up front), the install still succeeds and is **complete**: the bundle and every plain `GRANT` are applied, covering all existing objects. The skipped statements are printed under "Optional SQL — requires postgres" purely as information — they only cover EQL objects `postgres` might later create outside stash tooling, and every `stash eql install`/`eql upgrade` re-grants all objects anyway (the generated Supabase migration wraps them in a membership guard, so it is safe for any role). Do not treat them as a required follow-up, and never work around a grants failure by disabling anything — the install itself is no longer rolled back by a grants failure (the bundle commits in its own transaction; grants run after it), and a plain re-run of `eql install` re-applies the grants on an already-installed database. + The removed `--eql-version`, `--latest`, `--drizzle`, `--migration`, `--direct`, `--migrations-dir`, and `--exclude-operator-family` options fail clearly instead of being ignored. A request for EQL v2 points dump-recovery users to the upstream EQL 2.3.1 SQL release. New installs are EQL v3 only; its pinned bundle self-adapts when a database role cannot create the optional operator family. **`--database-url` is a one-shot.** It installs against that database and leaves the project untouched — no config is loaded, and none is scaffolded, nor is an encryption client. This lets `npx --package=stash@1.0.0 stash eql install --database-url 'postgres://...'` run in a bare project with no CipherStash dependencies while pinning the CLI to this skill's release. It also means the flag always wins: loading a config could pick up a parent-directory `databaseUrl` literal and install against the wrong database. @@ -679,9 +679,11 @@ import { const installer = new EQLInstaller({ databaseUrl: 'postgresql://...' }) await installer.preflight() // PreflightResult +await installer.checkPermissions() // deprecated adapter over preflight() await installer.isInstalled() // boolean (v3) await installer.getInstalledVersion() // string | 'unknown' | null await installer.install({ supabase: true }) // InstallResult +await installer.applySupabaseGrants() // InstallResult (grants only, idempotent) ``` `install` installs EQL v3 only and accepts `supabase`. `isInstalled` and `getInstalledVersion` retain an optional `{ eqlVersion: 2 | 3 }` solely for read-only diagnostics of existing v2 databases. @@ -694,10 +696,13 @@ type PreflightResult = { isSuperuser: boolean memberOfPostgres: boolean | null // null: no postgres role exists; false never blocks (it only skips optional statements) hasDatabaseCreate: boolean - hasPublicCreate: boolean + hasPublicCreate: boolean // false (blocking) when the public schema is missing entirely pgcryptoInstalled: boolean + pgcryptoSchema: string | null // blocks (even superusers) when outside extensions/public — the bundle aborts eqlV3SchemaPresent: boolean eqlV3InternalSchemaPresent: boolean + canDropEqlV3Schema: boolean | null // null: schema absent; false blocks reinstall (DROP SCHEMA ... CASCADE) + canDropEqlV3InternalSchema: boolean | null } type InstallResult = { From 1c5bea39aa7f520646cc1dfea9975df0c20b42cc Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 18 Aug 2026 12:07:31 +1000 Subject: [PATCH 4/4] test(cli): de-race the two live suites over the shared postgres role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both preflight.live and guarded-grants.live create the postgres role when the compose database lacks one, and vitest runs the files in parallel workers against the same database — the check-then-CREATE raced (pg_authid_rolname_index duplicate key, observed locally). Creation is now a duplicate_object-tolerant DO block in both files, neither drops the role afterwards (the compose database is ephemeral, and a drop from one file races the other's queries), and the missing-role assertion samples role existence on both sides of the probe so a mid-probe appearance accepts either membership arm. Three consecutive full live runs green. --- .../__tests__/guarded-grants.live.test.ts | 15 +++------- .../__tests__/preflight.live.test.ts | 29 ++++++++++--------- 2 files changed, 20 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/installer/__tests__/guarded-grants.live.test.ts b/packages/cli/src/installer/__tests__/guarded-grants.live.test.ts index 33da94079..a74871ac0 100644 --- a/packages/cli/src/installer/__tests__/guarded-grants.live.test.ts +++ b/packages/cli/src/installer/__tests__/guarded-grants.live.test.ts @@ -29,8 +29,6 @@ const OUTSIDER = `stash_guard_outsider_${process.pid}` const MEMBER = `stash_guard_member_${process.pid}` const PASSWORD = 'stash-guard-test' -let createdPostgresRole = false - async function query(sql: string, url?: string): Promise { const { default: pg } = await import('pg') const client = new pg.Client({ connectionString: url ?? DATABASE_URL }) @@ -69,13 +67,11 @@ describeLive('guarded owner-scoped grants — live Postgres', () => { } await query('CREATE SCHEMA IF NOT EXISTS eql_v3') await query('CREATE SCHEMA IF NOT EXISTS eql_v3_internal') - const rows = await query<{ n: number }>( - "SELECT count(*)::int AS n FROM pg_roles WHERE rolname = 'postgres'", + // Race-safe against the preflight live suite creating it concurrently; + // left behind afterwards for the same reason (the database is ephemeral). + await query( + 'DO $$ BEGIN CREATE ROLE postgres; EXCEPTION WHEN duplicate_object THEN NULL; END $$', ) - if (rows[0]?.n === 0) { - await query('CREATE ROLE postgres') - createdPostgresRole = true - } await query(`CREATE ROLE ${OUTSIDER} LOGIN PASSWORD '${PASSWORD}'`) await query(`CREATE ROLE ${MEMBER} LOGIN PASSWORD '${PASSWORD}'`) await query(`GRANT postgres TO ${MEMBER}`) @@ -100,9 +96,6 @@ describeLive('guarded owner-scoped grants — live Postgres', () => { ).catch(() => undefined) await query(`DROP ROLE IF EXISTS ${OUTSIDER}`).catch(() => undefined) await query(`DROP ROLE IF EXISTS ${MEMBER}`).catch(() => undefined) - if (createdPostgresRole) { - await query('DROP ROLE IF EXISTS postgres').catch(() => undefined) - } }) it('succeeds silently for a role that is not a member of postgres', async () => { diff --git a/packages/cli/src/installer/__tests__/preflight.live.test.ts b/packages/cli/src/installer/__tests__/preflight.live.test.ts index 88b724f3c..6252cc9c9 100644 --- a/packages/cli/src/installer/__tests__/preflight.live.test.ts +++ b/packages/cli/src/installer/__tests__/preflight.live.test.ts @@ -25,8 +25,6 @@ const describeLive = DATABASE_URL ? describe : describe.skip const MEMBER_ROLE = `stash_preflight_member_${process.pid}` const MEMBER_PASSWORD = 'stash-preflight-test' -let createdPostgresRole = false - async function adminQuery(sql: string): Promise { const { default: pg } = await import('pg') const client = new pg.Client({ connectionString: DATABASE_URL }) @@ -58,9 +56,9 @@ describeLive('EQLInstaller.preflight — live Postgres', () => { await adminQuery(`DROP ROLE IF EXISTS ${MEMBER_ROLE}`).catch( () => undefined, ) - if (createdPostgresRole) { - await adminQuery('DROP ROLE IF EXISTS postgres').catch(() => undefined) - } + // The `postgres` role is deliberately left behind: the guarded-grants + // live suite shares it in the same run, and the compose database is + // ephemeral (no volume), so dropping it here would only create races. }) it('runs the real preflight query, guarding pg_has_role against a missing postgres role', async () => { @@ -69,7 +67,11 @@ describeLive('EQLInstaller.preflight — live Postgres', () => { databaseUrl: DATABASE_URL as string, }) // The assertion that matters is that this does not throw: an unguarded - // pg_has_role('postgres') raises when the role is absent. + // pg_has_role('postgres') raises when the role is absent. The sibling + // guarded-grants suite may create the role concurrently, so sample + // existence on both sides of the probe and only pin the null arm when it + // was absent throughout. + const existedBefore = await postgresRoleExists() const result = await installer.preflight() expect(result.currentUser).toBe('cipherstash') expect(result.isSuperuser).toBe(true) @@ -87,20 +89,21 @@ describeLive('EQLInstaller.preflight — live Postgres', () => { } else { expect(result.canDropEqlV3Schema).toBeNull() } - if (await postgresRoleExists()) { - // Image variant that ships a postgres role: a superuser is a member. + const existedAfter = await postgresRoleExists() + if (existedBefore) { + // A superuser is a member of every role. expect(result.memberOfPostgres).toBe(true) - } else { + } else if (!existedAfter) { expect(result.memberOfPostgres).toBeNull() } + // Role appeared mid-probe (parallel suite): either arm is legitimate. }) it('reports membership truthfully for member and non-member roles', async () => { const { EQLInstaller } = await import('../index.js') - if (!(await postgresRoleExists())) { - await adminQuery('CREATE ROLE postgres') - createdPostgresRole = true - } + await adminQuery( + 'DO $$ BEGIN CREATE ROLE postgres; EXCEPTION WHEN duplicate_object THEN NULL; END $$', + ) await adminQuery( `CREATE ROLE ${MEMBER_ROLE} LOGIN PASSWORD '${MEMBER_PASSWORD}'`, )