diff --git a/apps/web/src/frontend/features/utilities/components/CloneTableModal.tsx b/apps/web/src/frontend/features/utilities/components/CloneTableModal.tsx index a60e3d48f..d0f53117c 100644 --- a/apps/web/src/frontend/features/utilities/components/CloneTableModal.tsx +++ b/apps/web/src/frontend/features/utilities/components/CloneTableModal.tsx @@ -238,11 +238,14 @@ export const CloneTableModal: React.FC = ({ schema: conn.schema?.trim() || undefined, }; try { - // Stop on first failure; chunk to backend MAX_STATEMENTS. + // Stop on first failure; chunk to backend MAX_STATEMENTS. The server + // stops too: without it, a failed index rename on the archive still + // ran the CREATE of the new live table after it, and "rename the + // archive back" then failed because the live name was taken. let ran = 0; for (let i = 0; i < executable.length; i += EXECUTE_BATCH_SIZE) { const batch = executable.slice(i, i + EXECUTE_BATCH_SIZE); - const result = await executeSql(ref, batch); + const result = await executeSql(ref, batch, undefined, undefined, undefined, { stopOnError: true }); for (let j = 0; j < result.results.length; j++) { const r = result.results[j]!; if (r.ok) { diff --git a/apps/web/src/frontend/shared/api/accessSql.test.ts b/apps/web/src/frontend/shared/api/accessSql.test.ts index 849bcdfe7..1250e638d 100644 --- a/apps/web/src/frontend/shared/api/accessSql.test.ts +++ b/apps/web/src/frontend/shared/api/accessSql.test.ts @@ -51,13 +51,36 @@ describe('runAccessSql', () => { expect(sent()).toEqual(['GRANT SELECT ON t TO r']); }); - it('joins the errors of the statements that failed', async () => { + it('asks the server to stop at the first failure', async () => { + // A script's later steps assume its earlier ones; running them anyway + // half-applies access the reader was never shown as a plan. + await runAccessSql(ref, 'GRANT SELECT ON t TO r;\nGRANT INSERT ON t TO r;'); + expect(executeSql.mock.calls[0]![5]).toEqual({ stopOnError: true }); + }); + + it('says which statement failed, what was applied before it and what did not run', async () => { + // The server stops at the failure, so the results end there. executeSql.mockResolvedValue({ results: [{ ok: true }, { ok: false, error: 'role "r" does not exist' }], }); + await expect( + runAccessSql(ref, 'GRANT USAGE ON SCHEMA s TO r;\nGRANT SELECT ON t TO r;\nGRANT INSERT ON t TO r;') + ).resolves.toEqual({ + ok: false, + error: 'Statement 2 of 3 failed: role "r" does not exist. The 1 before it was applied. The 1 after it did not run.', + }); + }); + + it('reports a lone statement’s error as it is', async () => { + executeSql.mockResolvedValue({ results: [{ ok: false, error: 'permission denied' }] }); + await expect(runAccessSql(ref, 'GRANT SELECT ON t TO r;')).resolves.toEqual({ ok: false, error: 'permission denied' }); + }); + + it('does not report ok when fewer results came back than statements were sent', async () => { + executeSql.mockResolvedValue({ results: [{ ok: true }] }); await expect(runAccessSql(ref, 'GRANT SELECT ON t TO r;\nGRANT INSERT ON t TO r;')).resolves.toEqual({ ok: false, - error: 'role "r" does not exist', + error: 'Only 1 of 2 statements ran.', }); }); diff --git a/apps/web/src/frontend/shared/api/accessSql.ts b/apps/web/src/frontend/shared/api/accessSql.ts index 440829166..1bcf8b2a4 100644 --- a/apps/web/src/frontend/shared/api/accessSql.ts +++ b/apps/web/src/frontend/shared/api/accessSql.ts @@ -20,29 +20,40 @@ import { splitSqlStatements } from '@foxschema/ui-shared'; import type { ConnectionRef } from './schemaApi'; import { executeSql } from './sqlApi'; -/** Every statement succeeded, or the joined errors of those that did not. */ +/** Every statement succeeded, or which one failed and what ran around it. */ export type AccessSqlOutcome = { ok: true } | { ok: false; error: string }; /** * Execute a grant/revoke script statement by statement. * - * Statements run in order and are not wrapped in a transaction: most engines - * auto-commit DDL anyway, so a mid-script failure leaves the earlier grants - * applied. The caller reports the failure and re-reads the catalog rather than - * claiming the script was undone. + * Statements run in order and stop at the first failure: a script's later + * steps assume its earlier ones (USAGE ON SCHEMA before ON ALL TABLES, CREATE + * USER before its quota), so running them against a state the script did not + * reach only adds errors or half-applied access. Nothing is wrapped in a + * transaction — most engines auto-commit DDL anyway — so the statements before + * the failure stay applied. The error says so, and the caller re-reads the + * catalog rather than claiming the script was undone. */ export async function runAccessSql( ref: ConnectionRef, sql: string ): Promise { - const statements = splitSqlStatements(sql) + const split = splitSqlStatements(sql) .map((s) => s.text.trim()) .filter(Boolean); - const { results } = await executeSql(ref, statements.length ? statements : [sql]); - const failed = results.filter((r) => !r.ok); - if (failed.length === 0) return { ok: true }; - return { - ok: false, - error: failed.map((r) => ('error' in r ? r.error : 'failed')).join(' · '), - }; + const statements = split.length ? split : [sql]; + const { results } = await executeSql(ref, statements, undefined, undefined, undefined, { stopOnError: true }); + const failedAt = results.findIndex((r) => !r.ok); + if (failedAt === -1 && results.length === statements.length) return { ok: true }; + if (failedAt === -1) { + return { ok: false, error: `Only ${results.length} of ${statements.length} statements ran.` }; + } + const failure = results[failedAt]!; + const error = 'error' in failure ? failure.error : 'failed'; + if (statements.length === 1) return { ok: false, error }; + const notRun = statements.length - failedAt - 1; + const before = failedAt === 0 ? '' : ` The ${failedAt} before it ${failedAt === 1 ? 'was' : 'were'} applied.`; + const after = notRun === 0 ? '' : ` The ${notRun} after it did not run.`; + const reason = error.trim().replace(/\.$/, ''); + return { ok: false, error: `Statement ${failedAt + 1} of ${statements.length} failed: ${reason}.${before}${after}` }; } diff --git a/apps/web/src/frontend/shared/api/sqlApi.ts b/apps/web/src/frontend/shared/api/sqlApi.ts index 197d1479f..ed375bc64 100644 --- a/apps/web/src/frontend/shared/api/sqlApi.ts +++ b/apps/web/src/frontend/shared/api/sqlApi.ts @@ -35,6 +35,8 @@ export async function executeSql( opts?: { datagridAction?: 'insert' | 'update' | 'delete'; seek?: { columns: string[]; values: unknown[]; descending?: boolean | boolean[] }; + /** Leave the statements after a failure unrun; `results` then ends at it. */ + stopOnError?: boolean; } ): Promise<{ results: SqlStatementResult[] }> { const data = await api.post<{ results?: SqlStatementResult[]; error?: string }>(`/sql/execute`, { @@ -45,6 +47,7 @@ export async function executeSql( params, ...(opts?.datagridAction ? { datagridAction: opts.datagridAction } : {}), ...(opts?.seek ? { seek: opts.seek } : {}), + ...(opts?.stopOnError ? { stopOnError: true } : {}), }); if (!data.results) throw new Error(data.error || 'Query failed: no results returned.'); return { results: data.results }; diff --git a/packages/server/src/features/access/access.routes.test.ts b/packages/server/src/features/access/access.routes.test.ts index a6ed0eef4..470fb6b19 100644 --- a/packages/server/src/features/access/access.routes.test.ts +++ b/packages/server/src/features/access/access.routes.test.ts @@ -40,34 +40,6 @@ async function serve(permissions: Permission[]) { return { server: app, resolveRef }; } -describe('table insight permissions', () => { - it('allows viewers who can run Data Peek queries', async () => { - const { server, resolveRef } = await serve(['editor.run']); - const res = await server.inject({ - method: 'POST', - url: '/schema/table-insight', - payload: { connectionId: 'c1', table: 'orders' }, - }); - - expect(res.statusCode).toBe(400); - expect(res.json().code).not.toBe('forbidden'); - expect(resolveRef).toHaveBeenCalledOnce(); - }); - - it('does not grant SQL Editor insight through Utilities alone', async () => { - const { server, resolveRef } = await serve(['utility.access']); - const res = await server.inject({ - method: 'POST', - url: '/schema/table-insight', - payload: { connectionId: 'c1', table: 'orders' }, - }); - - expect(res.statusCode).toBe(403); - expect(res.json()).toMatchObject({ code: 'forbidden', missing: ['editor.run'] }); - expect(resolveRef).not.toHaveBeenCalled(); - }); -}); - describe('db-access catalog permissions', () => { it('allows Access workspace viewers with access.access (not only utility.access)', async () => { const { server, resolveRef } = await serve(['access.access']); diff --git a/packages/server/src/features/access/access.routes.ts b/packages/server/src/features/access/access.routes.ts index 7f4cb8de2..7080fb1a8 100644 --- a/packages/server/src/features/access/access.routes.ts +++ b/packages/server/src/features/access/access.routes.ts @@ -17,7 +17,6 @@ import type { AuthedRequest } from '../../platform/http/types'; import type { ConnectionRef } from '../../platform/connections/resolve'; import { probeTableFragmentation, mapPool } from './index-fragmentation.service'; import { probeDbaUtility } from './dba-utilities.service'; -import { probeTableInsight } from '../schema'; import type { DbaUtilityKind } from '@foxschema/db'; import { probeDbAccess } from './db-access.service'; import { @@ -194,42 +193,6 @@ export function createAccessRoutes(deps: AccessRouteDeps): Router { } }); - router.post( - '/schema/table-insight', - dbaUtilityLimiter, - // This powers Data Peek's Insight tab in the SQL Editor. Requiring - // utility.access locked the whole tab out for the built-in viewer role, - // even though viewers can open Data Peek and run its read-only queries. - requirePermissions('editor.run'), - async (req: AppRequest, res: FastifyReply) => { - const body = req.body as ConnectionRef & { schema?: unknown; table?: unknown }; - const table = typeof body.table === 'string' ? body.table.trim() : ''; - if (!table) { - sendError(res, 'invalid_input', 'table is required.'); - return; - } - try { - const resolved = await deps.resolveRef(scopeOf(req as AuthedRequest), body); - const schema = - (typeof body.schema === 'string' && body.schema.trim()) || resolved.schema || ''; - const probed = await probeTableInsight({ - dialect: resolved.dialect, - option: resolved.option, - schema, - table, - }); - if (!probed.ok) { - const { status, ...rest } = probed.failure; - res.status(status).send(rest); - return; - } - res.send({ ...probed.value, dialect: resolved.dialect }); - } catch (error: unknown) { - sendThrown(res, error, 'Failed to load table insight'); - } - } - ); - router.post( '/schema/db-access', dbAccessLimiter, diff --git a/packages/server/src/features/access/index-fragmentation.service.test.ts b/packages/server/src/features/access/index-fragmentation.service.test.ts index a686a855d..7116e2713 100644 --- a/packages/server/src/features/access/index-fragmentation.service.test.ts +++ b/packages/server/src/features/access/index-fragmentation.service.test.ts @@ -162,6 +162,24 @@ describe('probeTableFragmentation against a real SQLite file', () => { expect(probed.failure.error).toMatch(/SELECT/); }); + it('refuses a SELECT … INTO hidden behind a comment marker in a string, before running it', async () => { + // On Postgres or SQL Server this creates a table. SQLite has no + // SELECT … INTO, so had it reached the database the failure would be its + // syntax error as a 500; refused first, it is a 400 naming the write. + const probed = await probeTableFragmentation({ + dialect: 'sqlite', + option, + schema: '', + table: 'orders', + preferCustom: true, + customSql: `SELECT '/*', 1 INTO fox_probe FROM orders -- */`, + }); + expect(probed.ok).toBe(false); + if (probed.ok) return; + expect(probed.failure.status).toBe(400); + expect(probed.failure.error).toMatch(/read-only/); + }); + it('rejects multi-statement custom SQL', async () => { const probed = await probeTableFragmentation({ dialect: 'sqlite', diff --git a/packages/server/src/features/access/index-fragmentation.service.ts b/packages/server/src/features/access/index-fragmentation.service.ts index f291d0691..b9e4dcacb 100644 --- a/packages/server/src/features/access/index-fragmentation.service.ts +++ b/packages/server/src/features/access/index-fragmentation.service.ts @@ -206,7 +206,7 @@ export async function probeTableFragmentation(opts: { }, }; } - const safe = isSafeIndexFragmentationCustomSql(customSql); + const safe = isSafeIndexFragmentationCustomSql(customSql, dialect); if (safe !== true) { return { ok: false, @@ -248,7 +248,7 @@ export async function probeTableFragmentation(opts: { const built = buildIndexFragmentationQuery({ dialect, schema, table }); if ('error' in built) { if (customSql) { - const safe = isSafeIndexFragmentationCustomSql(customSql); + const safe = isSafeIndexFragmentationCustomSql(customSql, dialect); if (safe !== true) { return { ok: false, @@ -355,7 +355,7 @@ export async function probeTableFragmentation(opts: { }, }; } - const safe = isSafeIndexFragmentationCustomSql(customSql); + const safe = isSafeIndexFragmentationCustomSql(customSql, dialect); if (safe !== true) { return { ok: false, diff --git a/packages/server/src/features/schema/index.ts b/packages/server/src/features/schema/index.ts index a1bbd96bd..50579f6ee 100644 --- a/packages/server/src/features/schema/index.ts +++ b/packages/server/src/features/schema/index.ts @@ -7,7 +7,6 @@ import type { ServerFeatureModule } from '../../platform/feature-module'; import { createSchemaRoutes } from './schema.routes'; -export { probeTableInsight } from './table-insight.service'; export const schemaFeature: ServerFeatureModule = { id: 'schema', diff --git a/packages/server/src/features/schema/schema.routes.ts b/packages/server/src/features/schema/schema.routes.ts index d209fbee7..236b849c5 100644 --- a/packages/server/src/features/schema/schema.routes.ts +++ b/packages/server/src/features/schema/schema.routes.ts @@ -3,7 +3,7 @@ * Copyright 2024-2026 Huy Phan * SPDX-License-Identifier: Apache-2.0 * - * Schema browse routes. + * Schema browse routes, and the table insight Data Peek shows. */ import { scopeOf } from '../../platform/http/scope'; import type { ConnectionResolver } from '../../platform/connections/resolve'; @@ -15,6 +15,8 @@ import type { AuthedRequest } from '../../platform/http/types'; import type { ConnectionRef } from '../../platform/connections/resolve'; import { getProviderSettings, type DbObjectType } from '@foxschema/db'; import { sendError, sendThrown } from '../../platform/http/respond'; +import { rateLimit } from '../../platform/guards/rate-limit'; +import { probeTableInsight } from './table-insight.service'; export interface SchemaRouteDeps { resolveRef: ConnectionResolver['resolveRef']; @@ -54,5 +56,43 @@ export function createSchemaRoutes(deps: SchemaRouteDeps): Router { } }); + const tableInsightLimiter = rateLimit({ windowMs: 60 * 1000, max: 30 }); + router.post( + '/schema/table-insight', + tableInsightLimiter, + // This powers Data Peek's Insight tab in the SQL Editor. Requiring + // utility.access locked the whole tab out for the built-in viewer role, + // even though viewers can open Data Peek and run its read-only queries. + requirePermissions('editor.run'), + async (req: AppRequest, res: FastifyReply) => { + const body = req.body as ConnectionRef & { schema?: unknown; table?: unknown }; + const table = typeof body.table === 'string' ? body.table.trim() : ''; + if (!table) { + sendError(res, 'invalid_input', 'table is required.'); + return; + } + try { + const resolved = await deps.resolveRef(scopeOf(req as AuthedRequest), body); + const schema = + (typeof body.schema === 'string' && body.schema.trim()) || resolved.schema || ''; + const probed = await probeTableInsight({ + dialect: resolved.dialect, + option: resolved.option, + schema, + table, + }); + if (!probed.ok) { + const { status, ...rest } = probed.failure; + res.status(status).send(rest); + return; + } + res.send({ ...probed.value, dialect: resolved.dialect }); + } catch (error: unknown) { + sendThrown(res, error, 'Failed to load table insight'); + } + } + ); + + return router; } diff --git a/packages/server/src/features/schema/table-insight.routes.test.ts b/packages/server/src/features/schema/table-insight.routes.test.ts new file mode 100644 index 000000000..ad4bf8d1f --- /dev/null +++ b/packages/server/src/features/schema/table-insight.routes.test.ts @@ -0,0 +1,67 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Who may load the table insight Data Peek shows in the SQL Editor. + */ +import Fastify, { type FastifyInstance } from 'fastify'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Permission } from '@foxschema/shared'; +import type { AuthedRequest } from '../../platform/http/types'; +import { bindRoutes } from '../../platform/http/fastify-bind'; +import { createSchemaRoutes } from './schema.routes'; + +let app: FastifyInstance | undefined; + +afterEach(async () => { + await app?.close(); + app = undefined; +}); + +async function serve(permissions: Permission[]) { + const resolveRef = vi.fn().mockResolvedValue({ + dialect: 'unsupported', + schema: 'public', + option: {}, + }); + const router = createSchemaRoutes({ resolveRef, connectionModule: {}, loadScopedTables: vi.fn() }); + app = Fastify(); + app.addHook('onRequest', async (req) => { + const authed = req as unknown as AuthedRequest; + authed.userId = 'viewer-user'; + authed.appRole = 'viewer'; + authed.permissions = new Set(permissions); + }); + bindRoutes(app, router.flatten()); + await app.ready(); + return { server: app, resolveRef }; +} + +describe('table insight permissions', () => { + it('allows viewers who can run Data Peek queries', async () => { + const { server, resolveRef } = await serve(['editor.run']); + const res = await server.inject({ + method: 'POST', + url: '/schema/table-insight', + payload: { connectionId: 'c1', table: 'orders' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().code).not.toBe('forbidden'); + expect(resolveRef).toHaveBeenCalledOnce(); + }); + + it('does not grant SQL Editor insight through Utilities alone', async () => { + const { server, resolveRef } = await serve(['utility.access']); + const res = await server.inject({ + method: 'POST', + url: '/schema/table-insight', + payload: { connectionId: 'c1', table: 'orders' }, + }); + + expect(res.statusCode).toBe(403); + expect(res.json()).toMatchObject({ code: 'forbidden', missing: ['editor.run'] }); + expect(resolveRef).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/server/src/features/sql-editor/editor.routes.ts b/packages/server/src/features/sql-editor/editor.routes.ts index 56cecc137..c9ac7ac40 100644 --- a/packages/server/src/features/sql-editor/editor.routes.ts +++ b/packages/server/src/features/sql-editor/editor.routes.ts @@ -74,12 +74,14 @@ export function createEditorRoutes(deps: EditorRouteDeps): Router { // called next(), so the request hung with no error and no response. const writeIdempotency = idempotency(); router.post('/sql/execute', sqlExecuteLimiter, writeIdempotency, async (req: AppRequest, res: FastifyReply) => { - const { statements, maxRows, offset, params, datagridAction, seek, ...ref } = req.body as ConnectionRef & { + const { statements, maxRows, offset, params, datagridAction, seek, stopOnError, ...ref } = req.body as ConnectionRef & { statements?: unknown; maxRows?: unknown; offset?: unknown; params?: unknown; seek?: unknown; + /** Leave the statements after a failure unrun (results end at it). */ + stopOnError?: unknown; /** Data Peek / query-result grid CRUD — requires editor.datagrid.*. */ datagridAction?: unknown; }; @@ -171,7 +173,8 @@ export function createEditorRoutes(deps: EditorRouteDeps): Router { resolved.schema, clampOffset(offset), (params as unknown[][] | undefined) ?? [], - parsedSeek.value + parsedSeek.value, + stopOnError === true ); res.send({ results }); } catch (error: unknown) { diff --git a/packages/server/src/features/sql-editor/sql-execute-gate.test.ts b/packages/server/src/features/sql-editor/sql-execute-gate.test.ts index 6448cdd82..5e7fb68d7 100644 --- a/packages/server/src/features/sql-editor/sql-execute-gate.test.ts +++ b/packages/server/src/features/sql-editor/sql-execute-gate.test.ts @@ -100,3 +100,17 @@ describe('POST /sql/execute reads a code fence as the database does', () => { expect(ran).toBe(false); }); }); + +describe('POST /sql/execute stopOnError', () => { + // runStatements' last argument: stop at the first failure. + const stopArg = () => vi.mocked(runStatements).mock.calls[0]!.at(-1); + + it('passes it to the statement loop only when it is true', async () => { + await execute('postgres', ['editor.run'], { statements: ['SELECT 1'], stopOnError: true }); + expect(stopArg()).toBe(true); + await execute('postgres', ['editor.run'], { statements: ['SELECT 1'] }); + expect(stopArg()).toBe(false); + await execute('postgres', ['editor.run'], { statements: ['SELECT 1'], stopOnError: 'yes' }); + expect(stopArg()).toBe(false); + }); +}); diff --git a/packages/server/src/features/sql-editor/sql-execute.service.test.ts b/packages/server/src/features/sql-editor/sql-execute.service.test.ts index bf0234563..8a167fb87 100644 --- a/packages/server/src/features/sql-editor/sql-execute.service.test.ts +++ b/packages/server/src/features/sql-editor/sql-execute.service.test.ts @@ -94,6 +94,38 @@ describe('runStatements against a real SQLite file', () => { if (results[2].ok) expect(results[2].rows).toEqual([[3]]); }); + it('with stopOnError, leaves every statement after a failure unrun', async () => { + // Clone Table's shape: a step fails, and the steps after it assumed it + // worked. Here the third statement would create a table if it ran. + const run = (stopOnError: boolean) => + runStatements( + 'sqlite', + { connectionString: dbPath }, + [ + 'CREATE TABLE IF NOT EXISTS stop_first (x INTEGER);', + 'INSERT INTO stop_missing VALUES (1);', + 'CREATE TABLE stop_after (x INTEGER);', + ], + 500, + undefined, + 0, + [], + undefined, + stopOnError + ); + const created = async () => + (await runStatements('sqlite', { connectionString: dbPath }, ["SELECT name FROM sqlite_master WHERE name = 'stop_after';"], 10))[0]; + + const stopped = await run(true); + expect(stopped.map((r) => r.ok)).toEqual([true, false]); + expect(await created()).toMatchObject({ ok: true, rowCount: 0 }); + + // Without it every statement runs, as the editor reports them. + const all = await run(false); + expect(all.map((r) => r.ok)).toEqual([true, false, true]); + expect(await created()).toMatchObject({ ok: true, rowCount: 1 }); + }); + it('applies the row cap with the truncated flag', async () => { const results = await runStatements('sqlite', { connectionString: dbPath }, ['SELECT * FROM t;'], 2); expect(results[0]).toMatchObject({ ok: true, rowCount: 2, truncated: true, hasNext: true }); diff --git a/packages/server/src/features/sql-editor/sql-execute.service.ts b/packages/server/src/features/sql-editor/sql-execute.service.ts index da79b12bd..67ea54bb2 100644 --- a/packages/server/src/features/sql-editor/sql-execute.service.ts +++ b/packages/server/src/features/sql-editor/sql-execute.service.ts @@ -124,7 +124,14 @@ export async function runStatements( offset = 0, /** Bind parameters per statement, aligned by index. Missing = no params. */ paramsList: readonly (readonly unknown[])[] = [], - seek?: SqlSeek + seek?: SqlSeek, + /** + * Stop at the first failed statement and leave the rest unrun. The results + * then end at that failure. A sequence whose later steps assume the earlier + * ones (Clone Table renames, then recreates) needs this; the editor runs + * every statement and reports each. + */ + stopOnError = false ): Promise { const schemaName = (schema ?? option.schema)?.trim() || ''; const optionWithSchema: ConnectionOptions = schemaName @@ -139,6 +146,7 @@ export async function runStatements( const results: StatementResult[] = []; for (const [index, original] of statements.entries()) { + if (stopOnError && results.at(-1)?.ok === false) break; const started = Date.now(); // Name any SELECT expression the user left unaliased. The grid keys its // columns off the row object, and Postgres calls every unaliased diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index 187e5411e..ab887ba40 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -120,7 +120,7 @@ export const PERMISSION_META: PermissionMeta[] = [ { id: 'editor.datagrid.update', group: 'Data grid', label: 'Update rows', description: 'Edit rows in Data Peek and editable query-result grids (also needs Change data).' }, { id: 'editor.datagrid.delete', group: 'Data grid', label: 'Delete rows', description: 'Delete rows in Data Peek and editable query-result grids (also needs Change data).' }, { id: 'utility.access', group: 'Utilities', label: 'Use utilities', description: 'Open the Utilities workspace (Index Management, Clone Table, Database Access, Server Insights, Query files). Database Access can list DB users/groups; running GRANT / REVOKE still needs Grant privileges.' }, - { id: 'utility.index.drop', group: 'Utilities', label: 'Drop indexes', description: 'Drop secondary indexes from Index Management. Constraint-backed indexes still need Edit table. Also needs Change schema.' }, + { id: 'utility.index.drop', group: 'Utilities', label: 'Drop indexes', description: 'Shows Drop for secondary indexes in Index Management (constraint-backed indexes still need Edit table). Also needs Change schema. It only governs that button: anyone with Change schema can still run DROP INDEX in the SQL Editor.' }, { id: 'secrets.view', group: 'Secrets', label: 'View secrets', description: 'List secrets (values still resolved only when needed).' }, { id: 'secrets.create', group: 'Secrets', label: 'Add secrets', description: 'Create vault entries and cloud provider credentials.' }, { id: 'secrets.edit', group: 'Secrets', label: 'Edit secrets', description: 'Update existing secrets and provider credentials.' }, diff --git a/packages/sql/src/modules/utilities/backup.test.ts b/packages/sql/src/modules/utilities/backup.test.ts index 8743a351a..8ddb8df86 100644 --- a/packages/sql/src/modules/utilities/backup.test.ts +++ b/packages/sql/src/modules/utilities/backup.test.ts @@ -309,6 +309,20 @@ describe('backups the server recorded', () => { ); }); + it('quotes a ClickHouse disk the way ClickHouse reads a string, backslashes included', () => { + // ClickHouse reads `\` as an escape in a string; undoubled, `back\ups` + // named another disk and a trailing `\` escaped the closing quote. + const c = ok(buildBackupCommands(conn('clickhouse', 'shop'), { folder: "back\\ups'1\\" }, NOW)); + expect(c.backup).toBe("BACKUP DATABASE shop TO Disk('back\\\\ups''1\\\\', 'shop_20261004_153005.zip');"); + }); + + it('reads a picked ClickHouse backup with escapes back to the same names', () => { + // As system.backups writes them: `\'` for a quote, `\\` for a backslash. + expect(restoreOf('clickhouse', "Disk('back\\\\ups', 'it\\'s.zip')").restore).toContain( + "FROM Disk('back\\\\ups', 'it''s.zip')" + ); + }); + it('ignores a pick it cannot read, rather than pasting it into SQL', () => { expect(restoreOf('db2', '2026; DROP', 'SAMPLE').restore).not.toContain('TAKEN AT'); const ch = restoreOf('clickhouse', "Disk('a') ; DROP DATABASE shop"); diff --git a/packages/sql/src/modules/utilities/index-fragmentation.test.ts b/packages/sql/src/modules/utilities/index-fragmentation.test.ts index 83713a07a..066181384 100644 --- a/packages/sql/src/modules/utilities/index-fragmentation.test.ts +++ b/packages/sql/src/modules/utilities/index-fragmentation.test.ts @@ -554,6 +554,51 @@ describe('custom SQL safety', () => { ).toMatch(/read-only/i); }); + it('reads a comment marker inside a string as text, so it cannot hide a write', () => { + // The database ignores only the trailing `-- */`; it creates the table or + // writes the file. Stripping `/* … */` before looking hid the INTO. + expect(isSafeIndexFragmentationCustomSql("SELECT '/*', 1 INTO fox_probe FROM t -- */", 'postgres')).toMatch( + /read-only/i + ); + expect( + isSafeIndexFragmentationCustomSql("SELECT '/*' AS a INTO OUTFILE '/tmp/fox' FROM t -- */", 'mysql') + ).toMatch(/read-only/i); + expect(isSafeIndexFragmentationCustomSql('SELECT 1 INTO fox_probe FROM t', 'sqlserver')).toMatch(/read-only/i); + }); + + it('runs what a fenced block hides: fence lines are only comments to the database', () => { + expect( + isSafeIndexFragmentationCustomSql('SELECT 1 AS index_name FROM t\n-- @node\nINTO fox_probe\n-- @end', 'postgres') + ).toMatch(/read-only/i); + }); + + it('counts statements as the dialect splits them', () => { + // A `;` inside a string is not a separator… + expect(isSafeIndexFragmentationCustomSql("SELECT ';' AS index_name, 0 AS fragmentation_percent", 'postgres')).toBe( + true + ); + // …and a trailing one is not a second statement. + expect(isSafeIndexFragmentationCustomSql('SELECT 1 AS index_name;', 'postgres')).toBe(true); + // MySQL reads `\'` as an escaped quote, so the string ends later and the + // `;` falls between two statements there. + expect(isSafeIndexFragmentationCustomSql("SELECT 'a\\'; DELETE FROM t; -- '", 'mysql')).toMatch( + /single statement/i + ); + }); + + it('accepts the template it offers for every dialect', () => { + for (const d of ['sqlserver', 'azuresql', 'postgres', 'cockroachdb', 'yugabytedb', 'mysql', 'mariadb', 'tidb', 'db2', 'oracle', 'sqlite', 'duckdb', 'clickhouse', 'redshift']) { + const template = buildIndexFragmentationCustomTemplate({ dialect: d, schema: 'public', table: 'orders' }); + expect(isSafeIndexFragmentationCustomSql(template, d), d).toBe(true); + } + }); + + it('keeps the leading comment block the custom templates start with', () => { + expect( + isSafeIndexFragmentationCustomSql('-- Custom probe\n/* edit the WHERE */\nSELECT 1 AS index_name, 0 AS fragmentation_percent', 'postgres') + ).toBe(true); + }); + it('builds a non-empty custom template per major dialect', () => { for (const d of [ 'sqlserver', diff --git a/packages/sql/src/modules/utilities/index-fragmentation.ts b/packages/sql/src/modules/utilities/index-fragmentation.ts index b47721e0d..cd9c6889b 100644 --- a/packages/sql/src/modules/utilities/index-fragmentation.ts +++ b/packages/sql/src/modules/utilities/index-fragmentation.ts @@ -26,7 +26,7 @@ * driver rows and checks custom SQL; it knows no catalog table by name. */ -import { isWriteStatement } from '../sql-text/sql-splitter.js'; +import { firstKeyword, sqlStatementCategories, sqlStatementCount } from '../sql-text/sql-splitter.js'; import { resolveIndexFragmentation } from './index-fragmentation.registry.js'; import { CUSTOM_HINT, @@ -341,30 +341,30 @@ export function buildIndexFragmentationCustomTemplate(opts: { } /** - * Reject obviously unsafe custom probes on the stats endpoint. - * Same trust model as the SQL editor for SELECTs, but refuse writes / multi-statements - * — including PostgreSQL data-modifying CTEs that still end in SELECT. + * Reject unsafe custom probes on the stats endpoint: one read-only statement. + * + * The endpoint runs this SQL without the SQL editor's permission gate, so it + * applies that gate's reading here: the shared tokenizer, as `dialect` reads + * the text, and the same per-statement categories. A regex that removed + * comments first did not know a `/*` inside a string is not a comment, and + * `SELECT '/*', 1 INTO t FROM x -- *\/` hid its INTO that way and created a + * table. */ -export function isSafeIndexFragmentationCustomSql(sql: string): true | string { +export function isSafeIndexFragmentationCustomSql(sql: string, dialect?: string): true | string { const t = sql.trim(); if (!t) return 'Custom SQL is empty.'; if (t.length > 20_000) return 'Custom SQL is too long.'; - if (/;/.test(t.replace(/;+\s*$/, ''))) { - return 'Custom SQL must be a single statement (no extra semicolons).'; - } - if (/--/.test(t) || /\/\*|\*\//.test(t)) { - // Allow leading comment block templates — strip line comments for verb check only. + if (sqlStatementCount(t, dialect) > 1) { + return 'Custom SQL must be a single statement.'; } - const stripped = t - .replace(/\/\*[\s\S]*?\*\//g, ' ') - .replace(/--[^\n]*/g, ' ') - .trim(); - if (!/^(with|select)\b/i.test(stripped)) { + const verb = firstKeyword(t); + if (verb !== 'select' && verb !== 'with') { return 'Custom SQL must be a SELECT (or WITH … SELECT) statement.'; } - // `WITH x AS (DELETE … RETURNING …) SELECT …` starts with WITH/SELECT but writes. - if (isWriteStatement(stripped)) { - return 'Custom SQL must be read-only (no INSERT/UPDATE/DELETE/DDL, including in CTEs).'; + // `WITH x AS (DELETE … RETURNING …) SELECT …` and `SELECT … INTO t` start + // like reads but write. + if (sqlStatementCategories(t, dialect).some((category) => category !== 'read')) { + return 'Custom SQL must be read-only (no INSERT/UPDATE/DELETE/DDL, SELECT … INTO, or writes in CTEs).'; } return true; } diff --git a/packages/sql/src/providers/clickHouse/clickhouse.backup.ts b/packages/sql/src/providers/clickHouse/clickhouse.backup.ts index bfa715198..fdb8411c5 100644 --- a/packages/sql/src/providers/clickHouse/clickhouse.backup.ts +++ b/packages/sql/src/providers/clickHouse/clickhouse.backup.ts @@ -10,23 +10,34 @@ * restore brings the database back under a new name, beside the live one. */ import type { BackupCommands, BackupConnection, BackupDialect, BackupRequest } from '../../modules/utilities/backup.types.js'; -import { sqlString } from '../../modules/utilities/backup-helpers.js'; const ident = (name: string) => (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) ? name : `\`${name.replace(/`/g, '``')}\``); +/** + * A ClickHouse string literal. ClickHouse reads a backslash inside one as an + * escape, so it is doubled before the quotes are: a disk named `back\ups` + * otherwise reached the server as another name, and a trailing backslash + * escaped the closing quote. + */ +const chString = (value: string) => `'${value.replace(/\\/g, '\\\\').replace(/'/g, "''")}'`; + +/** One literal's body as ClickHouse wrote it, read back to its value. */ +const chUnescape = (body: string) => body.replace(/''|\\(.)/g, (_m, escaped?: string) => escaped ?? "'"); + /** A backup's name in system.backups, `Disk('backups', 'x.zip')`, read back to its parts. */ function pickedDisk(name: string | undefined): { disk: string; file: string } | null { - const m = name ? /^Disk\('([^']*)',\s*'([^']*)'\)$/.exec(name.trim()) : null; - return m ? { disk: m[1]!, file: m[2]! } : null; + const literal = "'((?:[^'\\\\]|\\\\.|'')*)'"; + const m = name ? new RegExp(`^Disk\\(${literal},\\s*${literal}\\)$`).exec(name.trim()) : null; + return m ? { disk: chUnescape(m[1]!), file: chUnescape(m[2]!) } : null; } function build(conn: BackupConnection, req: BackupRequest): BackupCommands { const disk = req.folder.trim() || 'backups'; const file = `${req.fileName}${req.compress ? '.zip' : ''}`; - const target = `Disk(${sqlString(disk)}, ${sqlString(file)})`; + const target = `Disk(${chString(disk)}, ${chString(file)})`; // Rebuilt from its parts and quoted again, never pasted from the catalog. const picked = pickedDisk(req.restoreFrom); - const restoreTarget = picked ? `Disk(${sqlString(picked.disk)}, ${sqlString(picked.file)})` : target; + const restoreTarget = picked ? `Disk(${chString(picked.disk)}, ${chString(picked.file)})` : target; const db = ident(conn.database); const restored = ident(`${conn.database}_restored`); const what = req.tables.length > 0 ? req.tables.map((t) => `TABLE ${db}.${ident(t)}`).join(', ') : `DATABASE ${db}`;