diff --git a/.changeset/eql-preflight-deferred-grants.md b/.changeset/eql-preflight-deferred-grants.md new file mode 100644 index 000000000..62b1d20fe --- /dev/null +++ b/.changeset/eql-preflight-deferred-grants.md @@ -0,0 +1,12 @@ +--- +'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 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). +- 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.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 52c23852b..4b4191226 100644 --- a/packages/cli/src/__tests__/installer.test.ts +++ b/packages/cli/src/__tests__/installer.test.ts @@ -14,37 +14,187 @@ 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, + 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', () => { 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: [CAPABLE_ROW], 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({ + ok: true, + missing: [], + isSuperuser: true, + memberOfPostgres: true, + currentUser: 'postgres', + }) + }) + + it('accepts database-local CREATE privilege for installing pgcrypto', async () => { mockConnect.mockResolvedValue(undefined) mockQuery.mockResolvedValue({ - rows: [{ rolsuper: true, rolcreatedb: true }], + 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: true, + isSuperuser: false, + memberOfPostgres: false, }) }) - it('accepts database-local CREATE privilege for installing pgcrypto', async () => { + 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, + }) + }) + + it('blocks a relocated pgcrypto, even for a superuser', async () => { mockConnect.mockResolvedValue(undefined) - mockQuery - .mockResolvedValueOnce({ - rows: [{ rolsuper: false, rolcreatedb: false }], + 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, }) - .mockResolvedValueOnce({ rows: [{ has_create: true }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [{ has_create: true }], rowCount: 1 }) - .mockResolvedValueOnce({ rows: [], rowCount: 0 }) + 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' }) @@ -52,7 +202,7 @@ describe('EQLInstaller', () => { await expect(installer.checkPermissions()).resolves.toEqual({ ok: true, missing: [], - isSuperuser: false, + isSuperuser: true, }) }) @@ -90,7 +240,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 +254,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 +334,113 @@ 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 /) + } + }) + + 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/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..db05cffed 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -300,6 +300,32 @@ 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 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: [ + { + 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..5e1eb23a1 --- /dev/null +++ b/packages/cli/src/commands/db/__tests__/preflight.test.ts @@ -0,0 +1,114 @@ +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, + pgcryptoSchema: 'extensions', + eqlV3SchemaPresent: false, + eqlV3InternalSchemaPresent: false, + canDropEqlV3Schema: null, + canDropEqlV3InternalSchema: null, + 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( + '<- skips optional: 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('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, + // 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..06bfc6c08 --- /dev/null +++ b/packages/cli/src/commands/db/grants-report.ts @@ -0,0 +1,26 @@ +import * as p from '@clack/prompts' +import type { InstallResult } from '@/installer/index.js' + +/** + * 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 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(), 'Optional SQL — requires postgres') +} diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index f79509eaa..7b5fed75f 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,12 +153,27 @@ export async function installCommand( } else { s.stop('Database permissions verified.') } + if (supabase && permissions.memberOfPostgres !== true) { + 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.`, + ) + } if (!options.force) { s.start('Checking if EQL is already installed...') 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' @@ -165,9 +181,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..a98fa9a79 --- /dev/null +++ b/packages/cli/src/commands/db/preflight.ts @@ -0,0 +1,204 @@ +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' +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. 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, + json: boolean, +): Promise { + 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: json, + jsonErrors: json, + }) +} + +/** + * `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 + * skips the optional 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) + emitJsonError('preflight_failed', message) + process.exit(1) + } + // `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')) + + // 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, + ) + + const s = p.spinner() + 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 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.') +} + +/** 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)], + [ + 'member of postgres', + result.memberOfPostgres === null + ? 'n/a (no postgres role)' + : yesNo(result.memberOfPostgres), + result.memberOfPostgres === false + ? '<- skips optional: 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', + 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'], + [ + 'eql_v3_internal', + 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]) => + [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/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/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..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' @@ -36,6 +37,21 @@ 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 + /** + * 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 @@ -185,10 +201,14 @@ 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) } - p.log.info(messages.db.urlResolvedFromFlag) + if (!ctx.quiet) p.log.info(messages.db.urlResolvedFromFlag) return trimmed } @@ -203,7 +223,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 +233,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) @@ -224,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 fafff0b57..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 { PermissionCheckResult } 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..a74871ac0 --- /dev/null +++ b/packages/cli/src/installer/__tests__/guarded-grants.live.test.ts @@ -0,0 +1,115 @@ +/** + * 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' + +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') + // 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 $$', + ) + 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) + }) + + 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 new file mode 100644 index 000000000..6252cc9c9 --- /dev/null +++ b/packages/cli/src/installer/__tests__/preflight.live.test.ts @@ -0,0 +1,132 @@ +/** + * 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' + +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, + ) + // 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 () => { + 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. 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) + 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() + } + const existedAfter = await postgresRoleExists() + if (existedBefore) { + // A superuser is a member of every role. + expect(result.memberOfPostgres).toBe(true) + } 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') + await adminQuery( + 'DO $$ BEGIN CREATE ROLE postgres; EXCEPTION WHEN duplicate_object THEN NULL; END $$', + ) + 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..352b57474 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,76 @@ 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 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 = `-- 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. +` + +/** + * 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 6da7ea045..4b41fd33c 100644 --- a/packages/cli/src/installer/index.ts +++ b/packages/cli/src/installer/index.ts @@ -1,14 +1,22 @@ 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_GUARDED_DEFAULT_PRIVILEGES_SQL_V3, + SUPABASE_IMMEDIATE_GRANTS_SQL_V3, + SUPABASE_MIGRATION_GRANTS_SQL_V3, SUPABASE_PERMISSIONS_SQL_V3, supabaseInternalPermissionsSql, supabasePermissionsSql, @@ -36,12 +44,108 @@ export function supabaseGrantsFor(): string { return SUPABASE_PERMISSIONS_SQL_V3 } +/** + * 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 + /** + * 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 { + /** + * 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. 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 +} + +/** + * 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 + 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, + 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, + (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 @@ -49,54 +153,89 @@ 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() + } 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[] = [] - 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)', - ) + 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 (!pgcryptoInstalled && !hasDatabaseCreate) { + missing.push( + 'SUPERUSER or extension owner (required for CREATE EXTENSION pgcrypto)', + ) + } } - - const schemaCreateResult = await client.query(` - SELECT has_schema_privilege(current_user, 'public', 'CREATE') AS has_create - `) - if (!schemaCreateResult.rows[0]?.has_create) { + // 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( - 'CREATE on public schema (required for CREATE DOMAIN public.eql_v3_*)', + `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)`, ) } - - const pgcryptoResult = await client.query(` - SELECT 1 FROM pg_extension WHERE extname = 'pgcrypto' - `) + // 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 ( - (pgcryptoResult.rowCount === 0 || pgcryptoResult.rowCount === null) && - !dbCreateResult.rows[0]?.has_create + canDropEqlV3Schema === false || + canDropEqlV3InternalSchema === false ) { missing.push( - 'SUPERUSER or extension owner (required for CREATE EXTENSION pgcrypto)', + '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 { ok: missing.length === 0, missing, isSuperuser: false } + return { + currentUser: String(row.role_name ?? 'unknown'), + isSuperuser, + memberOfPostgres: asBoolOrNull(row.member_of_postgres), + hasDatabaseCreate, + hasPublicCreate: row.has_public_create === 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 { @@ -104,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 }) @@ -165,8 +319,21 @@ 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 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 }) try { await client.connect() @@ -178,18 +345,79 @@ 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 { + 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 — re-run \`stash eql install --force\` (or plain \`stash eql install\`, which re-applies the grants on an already-installed database).`, + { cause: error }, + ) } - await client.query('COMMIT') + } finally { + 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) { - await client.query('ROLLBACK').catch(() => {}) const detail = error instanceof Error ? error.message : String(error) - throw new Error(`Failed to install EQL: ${detail}`, { cause: 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/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..5953fec6e 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 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 | +|---|---| +| `--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` 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. @@ -362,6 +374,8 @@ Gets a project from zero to a direct EQL v3 install. It loads an existing `stash | `--supabase` | Supabase-compatible install; grants `anon`, `authenticated`, and `service_role` | | `--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. @@ -664,34 +678,56 @@ import { ```typescript const installer = new EQLInstaller({ databaseUrl: 'postgresql://...' }) -await installer.checkPermissions() // PermissionCheckResult +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 }) // executes in a transaction +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. ```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 (it only skips optional statements) + hasDatabaseCreate: 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 = { + // 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. Optional SQL — surface as information, never as a + // required step: stash re-grants every object on each install/upgrade. + 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..c605a4b35 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -89,6 +89,18 @@ 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 +> 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 encrypt` records per-column progress in. One `supabase db reset` therefore