From c3886803e027089447d14ca7a79b474dd8541789 Mon Sep 17 00:00:00 2001 From: huyplb Date: Mon, 5 Oct 2026 23:34:45 -0600 Subject: [PATCH] feat(access, backup): one Grants view that shows everything held; routine grants; run and list backups From the 2026-10-04 UX review's open recommendations. Access: - One Grants view instead of "Desired matrix" / "Live catalog". The grid, then "also holds, outside this grid" (database, schema and server-wide grants, DENY, grants on objects that are not rows), then the database- and schema-wide section to edit those (DbAccessPermissionSections generalOnly). splitHeldPrivileges / describeHeldPrivilege in @foxschema/sql. - Routine grants are read: Postgres role_routine_grants, MySQL/MariaDB mysql.procs_priv, Db2 SYSCAT.ROUTINEAUTH, so a procedure or function row opens ticked when EXECUTE is held. New object types PROCEDURE / FUNCTION / ROUTINE; revokes use each engine's routine syntax. - Oracle object grants no longer say ON TABLE, which Oracle rejects. Backup & Restore: - Run the backup from the panel where it is SQL the database server runs, after a confirmation that names the file and the connection; needs editor.ddl (the server classifies BACKUP as a schema change). - List the backups the server recorded (SQL Server msdb, Db2 DB_HISTORY, ClickHouse system.backups) and restore one: its key reaches the restore command checked and re-quoted, never pasted. Restores still only open in the SQL editor. Co-Authored-By: Claude Opus 5.5 --- apps/e2e/src/generated/test-ids.ts | 59 +++- .../access/components/AccessGrantsStage.tsx | 286 +++++++++--------- .../components/AccessPermissionPanel.test.tsx | 27 ++ .../components/DbAccessPermissionSections.tsx | 39 ++- .../frontend/features/access/lib/access.ts | 2 + .../components/BackupRestorePanel.test.tsx | 55 +++- .../components/BackupRestorePanel.tsx | 24 +- .../components/BackupServerActions.tsx | 227 ++++++++++++++ docs/testing/TEST_IDS.md | 26 +- packages/sql/src/index.ts | 7 + .../sql/src/modules/access/db-access.test.ts | 51 ++++ packages/sql/src/modules/access/db-access.ts | 77 ++++- .../modules/access/object-grid.held.test.ts | 77 ++++- .../sql/src/modules/access/object-grid.ts | 48 ++- .../sql/src/modules/utilities/backup.test.ts | 54 ++++ packages/sql/src/modules/utilities/backup.ts | 37 ++- .../sql/src/modules/utilities/backup.types.ts | 13 + .../providers/clickHouse/clickhouse.backup.ts | 17 +- packages/sql/src/providers/db2/db2.backup.ts | 15 +- .../providers/sqlServer/sqlserver.backup.ts | 14 +- 20 files changed, 963 insertions(+), 192 deletions(-) create mode 100644 apps/web/src/frontend/features/utilities/components/BackupServerActions.tsx diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index faeb1dc6b..50a5a913c 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -9,16 +9,14 @@ export const TestIds = { access: { AccessGrantsStage: { + /** p */ + accessGrantsAlsoHolds: 'access-grants-also-holds', /** li */ accessGrantsChange: (action: string | number) => `access-grants-change-${action}`, /** button · Copy */ accessGrantsCopy: 'access-grants-copy', /** ul */ accessGrantsDiff: 'access-grants-diff', - /** div */ - accessGrantsMode: 'access-grants-mode', - /** button */ - accessGrantsModeForValue: (value: string | number) => `access-grants-mode-${value}`, /** button · Open in SQL Editor */ accessGrantsOpenSql: 'access-grants-open-sql', /** button */ @@ -2667,15 +2665,45 @@ export const TestIds = { backupTables: 'backup-tables', /** div · No backup commands */ backupUnsupported: 'backup-unsupported', - /** section · Restore */ + /** section */ restoreCommand: 'restore-command', - /** button · Restore */ + /** button */ restoreCommandCopy: 'restore-command-copy', /** button · Open in SQL Editor */ restoreCommandOpenSql: 'restore-command-open-sql', - /** pre · Restore */ + /** pre */ restoreCommandText: 'restore-command-text', }, + BackupServerActions: { + /** ul */ + backupHistory: 'backup-history', + /** button · Restore the newest instead */ + backupHistoryClearPick: 'backup-history-clear-pick', + /** p */ + backupHistoryEmpty: 'backup-history-empty', + /** p */ + backupHistoryError: 'backup-history-error', + /** button */ + backupHistoryLoad: 'backup-history-load', + /** button */ + backupHistoryPick: (i: string | number) => `backup-history-pick-${i}`, + /** li */ + backupHistoryRow: (i: string | number) => `backup-history-row-${i}`, + /** button · Run backup now */ + backupRun: 'backup-run', + /** button · Cancel */ + backupRunCancel: 'backup-run-cancel', + /** button · Run it */ + backupRunConfirm: 'backup-run-confirm', + /** div */ + backupRunConfirmBox: 'backup-run-confirm-box', + /** p */ + backupRunError: 'backup-run-error', + /** span */ + backupRunStatus: 'backup-run-status', + /** section */ + backupServerActions: 'backup-server-actions', + }, CloneTableModal: { /** button · Apply clone */ cloneApply: 'clone-apply', @@ -3304,11 +3332,10 @@ export const TestIds = { /** Any test ID in the web app; `${string}` is a part filled in at run time. */ export type TestId = | 'access-connection' + | 'access-grants-also-holds' | `access-grants-change-${string}` | 'access-grants-copy' | 'access-grants-diff' - | 'access-grants-mode' - | `access-grants-mode-${string}` | 'access-grants-open-sql' | `access-grants-preset-${string}` | 'access-grants-presets' @@ -3471,16 +3498,30 @@ export type TestId = | 'backup-folder' | 'backup-format' | `backup-format-${string}` + | 'backup-history' + | 'backup-history-clear-pick' + | 'backup-history-empty' + | 'backup-history-error' + | 'backup-history-load' + | `backup-history-pick-${string}` + | `backup-history-row-${string}` | 'backup-limit-schema' | 'backup-no-connection' | 'backup-notes' | 'backup-panel' | 'backup-reset' + | 'backup-run' + | 'backup-run-cancel' + | 'backup-run-confirm' + | 'backup-run-confirm-box' + | 'backup-run-error' + | 'backup-run-status' | 'backup-runs-on' | 'backup-save-default' | 'backup-save-status' | 'backup-scope' | `backup-scope-${string}` + | 'backup-server-actions' | 'backup-tables' | 'backup-unsupported' | 'blueprint-add-column' diff --git a/apps/web/src/frontend/features/access/components/AccessGrantsStage.tsx b/apps/web/src/frontend/features/access/components/AccessGrantsStage.tsx index bc9318197..f9715521a 100644 --- a/apps/web/src/frontend/features/access/components/AccessGrantsStage.tsx +++ b/apps/web/src/frontend/features/access/components/AccessGrantsStage.tsx @@ -16,7 +16,8 @@ import React, { useCallback, useMemo, useState } from 'react'; import { Copy, FileCode2 } from 'lucide-react'; import { buildAccessSql, - heldGridPermissions, + splitHeldPrivileges, + describeHeldPrivilege, permissionsForPreset, type AccessPermission, type AccessPreset, @@ -26,7 +27,6 @@ import { } from '../lib/access'; import { useAllSchemaObjects } from '../lib/useAllSchemaObjects'; import { PermissionMatrix } from './PermissionMatrix'; -import { Segmented } from './controls'; import { DbAccessPermissionSections, type DbAccessConfirmRequest, @@ -63,8 +63,6 @@ function changeLabel(action: 'grant' | 'revoke', req: PermissionRequest): string return `${action === 'grant' ? 'Grant' : 'Revoke'} ${privileges} on ${where}`; } -type GrantsMode = 'matrix' | 'live'; - export const AccessGrantsStage: React.FC<{ dialect: string; connectionId: string; @@ -88,7 +86,6 @@ export const AccessGrantsStage: React.FC<{ onConfirm, onError, }) => { - const [mode, setMode] = useState('matrix'); const [activePreset, setActivePreset] = useState('custom'); const [gridPreset, setGridPreset] = useState<{ permissions: AccessPermission[]; @@ -96,7 +93,7 @@ export const AccessGrantsStage: React.FC<{ } | null>(null); const [changes, setChanges] = useState({ grant: [], revoke: [] }); const [resetNonce, setResetNonce] = useState(undefined); - const catalog = useAllSchemaObjects(connectionId, mode === 'matrix'); + const catalog = useAllSchemaObjects(connectionId, true); const accessPrincipal = useMemo( () => ({ @@ -106,11 +103,15 @@ export const AccessGrantsStage: React.FC<{ [principal] ); - /** What this principal holds on the grid's objects, so the grid opens on it. */ - const held = useMemo( - () => heldGridPermissions(privileges, catalog.objects, dialect, defaultSchema || ''), + /** + * What this principal holds on the grid's objects, so the grid opens on it, + * and what else it holds, which the grid cannot show but must not hide. + */ + const { held, elsewhere } = useMemo( + () => splitHeldPrivileges(privileges, catalog.objects, dialect, defaultSchema || ''), [privileges, catalog.objects, dialect, defaultSchema] ); + const alsoHolds = useMemo(() => [...new Set(elsewhere.map(describeHeldPrivilege))], [elsewhere]); const onChanges = useCallback((next: GridChanges) => setChanges(next), []); const sqlText = useMemo(() => { @@ -152,149 +153,144 @@ export const AccessGrantsStage: React.FC<{ return (
-
- setMode(v as GrantsMode)} - options={[ - { value: 'matrix', label: 'Desired matrix' }, - { value: 'live', label: 'Live catalog' }, - ]} - /> -

- Fox Schema generates GRANT/REVOKE SQL — it does not apply it. -

-
+

+ Fox Schema generates GRANT/REVOKE SQL — it does not apply it. +

- {mode === 'live' ? ( - + + Presets + + {(Object.keys(PRESET_LABEL) as Exclude[]).map((p) => ( + + ))} + - ))} - -
+ className="rounded-md border border-slate-700 px-2.5 py-1 text-[11px] font-semibold text-slate-500 hover:text-slate-300" + > + Reset to current + + - {catalog.loading && ( -

Reading schema objects…

- )} + {catalog.loading && ( +

Reading schema objects…

+ )} - + - {changes.grant.length + changes.revoke.length > 0 && ( -
    - {[ - ...changes.grant.map((r) => ['grant', r] as const), - ...changes.revoke.map((r) => ['revoke', r] as const), - ].map(([action, r], i) => ( -
  • - {changeLabel(action, r)} -
  • - ))} -
- )} + {alsoHolds.length > 0 && ( +

+ + {principal.name} also holds, outside this grid: + {' '} + {alsoHolds.join(' · ')} +

+ )} -
-
-

- Grant SQL -

-
- - -
-
-
-              {sqlText.trim() ||
-                (catalog.loading
-                  ? 'Reading schema objects…'
-                  : `Nothing to change: the grid matches what ${principal.name} holds now. Tick a box to grant it, untick one to revoke it.`)}
-            
-

- Fox Schema does not apply this SQL. The database stays the source of truth. -

-
- + {changes.grant.length + changes.revoke.length > 0 && ( +
    + {[ + ...changes.grant.map((r) => ['grant', r] as const), + ...changes.revoke.map((r) => ['revoke', r] as const), + ].map(([action, r], i) => ( +
  • + {changeLabel(action, r)} +
  • + ))} +
)} + +
+
+

+ Grant SQL +

+
+ + +
+
+
+          {sqlText.trim() ||
+            (catalog.loading
+              ? 'Reading schema objects…'
+              : `Nothing to change: the grid matches what ${principal.name} holds now. Tick a box to grant it, untick one to revoke it.`)}
+        
+

+ Fox Schema does not apply this SQL. The database stays the source of truth. +

+
+ {/* + * Database- and schema-wide grants: a different scope from the + * grid's rows, so a section of their own rather than a second + * mode that redrew the same objects another way. + */} + ); }; diff --git a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx index e12771bdc..9f201d2db 100644 --- a/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx +++ b/apps/web/src/frontend/features/access/components/AccessPermissionPanel.test.tsx @@ -217,6 +217,33 @@ describe('AccessPermissionPanel — one session', () => { expect(screen.getByTestId('matrix-summary').textContent).toMatch(/1 privilege held now · 0 to grant · 0 to revoke/); }); + it('is one view: the grid, what else is held, and database-wide grants under it', async () => { + const wide = (privilege: string, objectType: 'DATABASE' | 'SCHEMA', objectName: string) => ({ + grantee: 'alice', + privilege, + objectType, + objectSchema: null, + objectName, + grantable: false, + grantor: null, + state: 'grant' as const, + }); + fetchDbAccess.mockResolvedValue({ + ...catalog, + privileges: [...catalog.privileges, wide('CONNECT', 'DATABASE', 'shop'), wide('USAGE', 'SCHEMA', 'public')], + }); + await grantsStageFor('alice'); + await waitFor(() => expect(cell('read').checked).toBe(true)); + // The grid cannot draw these, so it says they exist rather than hiding them. + const also = await screen.findByTestId('access-grants-also-holds'); + expect(also.textContent).toMatch(/CONNECT on database shop/); + expect(also.textContent).toMatch(/USAGE on schema public/); + // Database- and schema-wide grants are edited below; there is no second mode. + expect(screen.getByTestId('db-access-section-general')).toBeTruthy(); + expect(screen.queryByTestId('db-access-section-table')).toBeNull(); + expect(screen.queryByTestId('access-grants-mode')).toBeNull(); + }); + it('grants a new tick without revoking what is already held', async () => { await grantsStageFor('alice'); await waitFor(() => expect(cell('read').checked).toBe(true)); diff --git a/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.tsx b/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.tsx index d05be45c4..a99428a17 100644 --- a/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.tsx +++ b/apps/web/src/frontend/features/access/components/DbAccessPermissionSections.tsx @@ -199,6 +199,12 @@ interface Props { * but the parent copies it or opens the SQL Editor instead of executing. */ generateOnly?: boolean; + /** + * Only the database- and schema-wide section. The Access Grants view draws + * objects in its own grid and shows this section under it for the grants + * the grid cannot hold. + */ + generalOnly?: boolean; onConfirm: (req: DbAccessConfirmRequest) => void; onError?: (message: string) => void; } @@ -218,6 +224,7 @@ export const DbAccessPermissionSections: React.FC = ({ grantSupported, running = false, generateOnly = false, + generalOnly = false, onConfirm, onError, }) => { @@ -426,19 +433,23 @@ export const DbAccessPermissionSections: React.FC = ({ return (
-
-
- Permissions -
- - Dialect-aware GRANT/REVOKE · {dialect} - -
-

- Expand a section to load objects, then Grant{' '} - on a row to set privileges — a row showing — holds none yet. Preview SQL uses this dialect’s - emitter (Postgres, MySQL, SQL Server, Oracle, Db2, … each differ). -

+ {!generalOnly && ( + <> +
+
+ Permissions +
+ + Dialect-aware GRANT/REVOKE · {dialect} + +
+

+ Expand a section to load objects, then Grant{' '} + on a row to set privileges — a row showing — holds none yet. Preview SQL uses this dialect’s + emitter (Postgres, MySQL, SQL Server, Oracle, Db2, … each differ). +

+ + )} {/* General */}
@@ -520,7 +531,7 @@ export const DbAccessPermissionSections: React.FC = ({
{/* Object kinds */} - {KIND_ORDER.map((kind) => { + {!generalOnly && KIND_ORDER.map((kind) => { const meta = KIND_META[kind]; const open = !!expanded[kind]; const rows = open ? objectsOfKind(kind) : []; diff --git a/apps/web/src/frontend/features/access/lib/access.ts b/apps/web/src/frontend/features/access/lib/access.ts index c63d93796..8e02a5878 100644 --- a/apps/web/src/frontend/features/access/lib/access.ts +++ b/apps/web/src/frontend/features/access/lib/access.ts @@ -30,6 +30,8 @@ export { compileGridChanges, gridObjectKey, heldGridPermissions, + splitHeldPrivileges, + describeHeldPrivilege, expandToInstance, accessStatementPlace, qualifyDatabaseSql, diff --git a/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.test.tsx b/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.test.tsx index 8b2e0bb69..9bada1a1c 100644 --- a/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.test.tsx +++ b/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.test.tsx @@ -10,7 +10,7 @@ import { BackupRestorePanel } from './BackupRestorePanel'; const connections = [ { id: 'pg', name: 'Shop', dialect: 'postgres', host: 'db.local', port: 5432, database: 'shop', schema: 'sales', username: 'fox' }, - { id: 'ms', name: 'Ledger', dialect: 'sqlserver', host: 'sql.local', port: 1433, database: 'ledger', username: 'sa_like' }, + { id: 'ms', name: 'Ledger', dialect: 'sqlserver', host: 'sql.local', port: 1433, database: 'ledger', username: 'sa_like', hasPassword: true }, { id: 'odd', name: 'Odd', dialect: 'notadb', database: 'x' }, ]; vi.mock('@/app/store/useSyncStore', () => ({ @@ -19,9 +19,15 @@ vi.mock('@/app/store/useSyncStore', () => ({ const setSql = vi.fn(); const ensureConnectionSelected = vi.fn(); vi.mock('@/app/store/useSqlEditorStore', () => ({ - useSqlEditorStore: (sel: (s: { setSql: typeof setSql; ensureConnectionSelected: typeof ensureConnectionSelected }) => unknown) => - sel({ setSql, ensureConnectionSelected }), + useSqlEditorStore: (sel: (s: { setSql: typeof setSql; ensureConnectionSelected: typeof ensureConnectionSelected; sessionPasswords: Record }) => unknown) => + sel({ setSql, ensureConnectionSelected, sessionPasswords: {} }), })); +let canChangeSchema = true; +vi.mock('@/app/store/authStore', () => ({ + useAuthStore: (sel: (s: { can: () => boolean }) => unknown) => sel({ can: () => canChangeSchema }), +})); +const executeSql = vi.fn(); +vi.mock('@/shared/api/sqlApi', () => ({ executeSql: (...args: unknown[]) => executeSql(...args) })); const setActiveView = vi.fn(); vi.mock('@/app/store/uiStore', () => ({ useUiStore: (sel: (s: { setActiveView: typeof setActiveView }) => unknown) => sel({ setActiveView }), @@ -37,6 +43,7 @@ vi.mock('@/shared/utils/clipboard', () => ({ writeClipboard: vi.fn(() => Promise const backupText = () => screen.getByTestId('backup-command-text').textContent ?? ''; beforeEach(() => { + canChangeSchema = true; getSettings.mockResolvedValue({}); saveSettings.mockImplementation((_d: string, s: unknown) => Promise.resolve(s)); }); @@ -119,4 +126,46 @@ describe('BackupRestorePanel', () => { await waitFor(() => expect(screen.getByTestId('backup-save-status').textContent).toMatch(/could not be loaded/)); expect(backupText()).toMatch(/^pg_dump /); }); + + it('runs SQL Server’s backup on the server once confirmed, and restores a listed backup', async () => { + const ok = (columns: string[], rows: unknown[][], durationMs = 1) => ({ + results: [{ ok: true, columns, rows, rowCount: rows.length, truncated: false, durationMs }], + }); + executeSql + .mockResolvedValueOnce(ok([], [], 1200)) + .mockResolvedValueOnce( + ok(['finished_at', 'location', 'size_bytes', 'restore_key'], [ + ['2026-10-05 22:00', '/var/opt/mssql/backups/ledger_old.bak', 2_097_152, '/var/opt/mssql/backups/ledger_old.bak'], + ]) + ); + render(); + await waitFor(() => expect(getSettings).toHaveBeenCalled()); + + // Nothing runs until the reader has seen where the file goes and agreed. + fireEvent.click(screen.getByTestId('backup-run')); + expect(executeSql).not.toHaveBeenCalled(); + expect(screen.getByTestId('backup-run-confirm-box').textContent).toMatch(/Ledger/); + fireEvent.click(screen.getByTestId('backup-run-confirm')); + await waitFor(() => expect(screen.getByTestId('backup-run-status').textContent).toMatch(/Backup finished in 1\.2 s/)); + expect(executeSql.mock.calls[0]![1]).toEqual([expect.stringMatching(/^BACKUP DATABASE \[ledger\]/)]); + + // The finished backup is listed (read straight after), and picking it points the restore at it. + fireEvent.click(await screen.findByTestId('backup-history-pick-0')); + expect(screen.getByTestId('restore-command-text').textContent).toContain( + "FROM DISK = N'/var/opt/mssql/backups/ledger_old.bak'" + ); + }); + + it('offers nothing to run or list for a tool that runs on the reader’s machine', async () => { + render(); + await waitFor(() => expect(getSettings).toHaveBeenCalled()); + expect(screen.queryByTestId('backup-server-actions')).toBeNull(); + }); + + it('will not run a backup for someone who may not change the schema', async () => { + canChangeSchema = false; + render(); + await waitFor(() => expect(getSettings).toHaveBeenCalled()); + expect((screen.getByTestId('backup-run') as HTMLButtonElement).disabled).toBe(true); + }); }); diff --git a/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.tsx b/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.tsx index 9be268522..34ef84f88 100644 --- a/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.tsx +++ b/apps/web/src/frontend/features/utilities/components/BackupRestorePanel.tsx @@ -26,6 +26,7 @@ import { type BackupRunsOn, type BackupScope, type BackupSettings, + type BackupHistoryEntry, } from '@foxschema/sql'; import { useSyncStore } from '@/app/store/useSyncStore'; import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; @@ -33,6 +34,7 @@ import { useUiStore } from '@/app/store/uiStore'; import { apiGetBackupSettings, apiSaveBackupSettings } from '@/shared/api/backupApi'; import { writeClipboard } from '@/shared/utils/clipboard'; import { dialectLabel } from '@/shared/lib/dialectLabel'; +import { BackupServerActions } from './BackupServerActions'; import { EmptyState, Field, Segmented, inputCls } from '@/features/access/components/controls'; const RUNS_ON: Record }> = { @@ -131,6 +133,8 @@ export const BackupRestorePanel: React.FC<{ lockedConnectionId?: string }> = ({ const [fileName, setFileName] = useState(''); const [tablesText, setTablesText] = useState(''); const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | { error: string }>('idle'); + /** A backup picked from the server's history, for the restore to read. */ + const [picked, setPicked] = useState(null); useEffect(() => { let live = true; @@ -153,6 +157,7 @@ export const BackupRestorePanel: React.FC<{ lockedConnectionId?: string }> = ({ setFileName(conn?.database ? backupFileName(conn.database, new Date()) : ''); setTablesText(''); setSaveState('idle'); + setPicked(null); // eslint-disable-next-line react-hooks/exhaustive-deps -- `saved` arrives on its own below }, [lockedConnectionId, dialect, conn?.database]); // Saved defaults arrive after the first paint. They apply only if the reader @@ -174,9 +179,9 @@ export const BackupRestorePanel: React.FC<{ lockedConnectionId?: string }> = ({ schema: conn.schema, username: conn.username, }, - { ...settings, fileName, tables: parseTableList(tablesText) } + { ...settings, fileName, tables: parseTableList(tablesText), restoreFrom: picked?.restoreKey } ); - }, [conn, support, dialect, settings, fileName, tablesText]); + }, [conn, support, dialect, settings, fileName, tablesText, picked]); const baseline = savedForDialect ?? defaultBackupSettings(dialect); const changed = !sameSettings(settings, baseline); @@ -228,7 +233,11 @@ export const BackupRestorePanel: React.FC<{ lockedConnectionId?: string }> = ({

{support.tool} - · Fox Schema writes these commands; it does not run them. + + {support.runsOn === 'server' && language === 'sql' + ? ' · Fox Schema writes these commands, and runs the backup only when you ask it to.' + : ' · Fox Schema writes these commands; it does not run them.'} +

= ({ canOpenInEditor={language === 'sql'} onOpen={() => openInEditor(commands.backup)} /> + + * SPDX-License-Identifier: Apache-2.0 + * + * What the Backup & Restore panel can do on the database server itself. + * + * Where a backup is SQL the server executes (SQL Server, CockroachDB, + * ClickHouse, DuckDB), it can run it, after the reader confirms what it will + * write and where. Where the server records its backups (SQL Server's msdb, + * Db2's history, ClickHouse's system.backups), it lists them, and picking one + * points the restore command at that backup instead of the newest in the + * folder. A restore is never run from here: it replaces a database, so it + * stays a command the reader opens in the SQL editor and runs themselves. + */ +import React, { useState } from 'react'; +import { History, Loader2, Play } from 'lucide-react'; +import { + backupHistoryQuery, + normalizeBackupHistory, + type BackupCommands, + type BackupHistoryEntry, +} from '@foxschema/sql'; +import { useAuthStore } from '@/app/store/authStore'; +import { useSqlEditorStore } from '@/app/store/useSqlEditorStore'; +import type { useSyncStore } from '@/app/store/useSyncStore'; +import { executeSql } from '@/shared/api/sqlApi'; +import { connectionNeedsSecret } from '@/shared/lib/provider-settings'; +import { dialectLabel } from '@/shared/lib/dialectLabel'; + +type Connection = ReturnType['connections'][number]; + +const formatSize = (bytes: number | null) => + bytes == null ? '' : bytes >= 1 << 30 ? `${(bytes / (1 << 30)).toFixed(1)} GB` : bytes >= 1 << 20 ? `${(bytes / (1 << 20)).toFixed(1)} MB` : `${Math.max(1, Math.round(bytes / 1024))} KB`; + +/** A result's rows as records, the shape the history normaliser reads. */ +const asRecords = (columns: string[], rows: unknown[][]) => + rows.map((row) => Object.fromEntries(columns.map((c, i) => [c, row[i]]))); + +export const BackupServerActions: React.FC<{ + connection: Connection; + /** The backup is SQL the database server runs, so it can be run from here. */ + runnable: boolean; + commands: BackupCommands; + picked: BackupHistoryEntry | null; + onPick: (entry: BackupHistoryEntry | null) => void; +}> = ({ connection, runnable, commands, picked, onPick }) => { + const sessionPasswords = useSqlEditorStore((s) => s.sessionPasswords); + const canRun = useAuthStore((s) => s.can('editor.ddl')); + const [confirming, setConfirming] = useState(false); + const [run, setRun] = useState<'idle' | 'running' | { ok: true; ms: number } | { error: string }>('idle'); + const [history, setHistory] = useState<'idle' | 'loading' | BackupHistoryEntry[] | { error: string }>('idle'); + + const historySql = backupHistoryQuery({ dialect: connection.dialect, database: connection.database ?? '', schema: connection.schema }); + const needsPassword = + !connection.hasPassword && + connectionNeedsSecret(connection.dialect, connection.authMethod) && + !sessionPasswords[connection.id]; + const ref = { + connectionId: connection.id, + password: sessionPasswords[connection.id] || undefined, + schema: connection.schema?.trim() || undefined, + }; + + if (!runnable && !historySql) return null; + + const runBackup = async () => { + setConfirming(false); + setRun('running'); + try { + const { results } = await executeSql(ref, [commands.backup]); + const result = results[0]; + setRun(result && result.ok ? { ok: true, ms: result.durationMs } : { error: result && !result.ok ? result.error : 'The server returned no result.' }); + if (result?.ok && historySql) void loadHistory(); + } catch (err) { + setRun({ error: err instanceof Error ? err.message : 'The backup could not run.' }); + } + }; + + const loadHistory = async () => { + if (!historySql) return; + setHistory('loading'); + try { + const { results } = await executeSql(ref, [historySql]); + const result = results[0]; + if (!result || !result.ok) { + setHistory({ error: result && !result.ok ? result.error : 'The server returned no result.' }); + return; + } + setHistory(normalizeBackupHistory(asRecords(result.columns, result.rows))); + } catch (err) { + setHistory({ error: err instanceof Error ? err.message : 'Could not read the backup history.' }); + } + }; + + const passwordHint = needsPassword ? 'Enter this connection’s password in the SQL Editor first.' : undefined; + + return ( +
+ {runnable && ( +
+
+ + + {run === 'running' + ? 'Running on the database server…' + : typeof run === 'object' && 'ok' in run + ? `Backup finished in ${(run.ms / 1000).toFixed(1)} s: ${commands.location}` + : null} + +
+ {confirming && ( +
+

+ {dialectLabel(connection.dialect)} will write {commands.location} on the + database server for {connection.name}. A large database can take a + while, and the server needs room for the file. +

+
+ + +
+
+ )} + {typeof run === 'object' && 'error' in run && ( +

+ {run.error} +

+ )} +
+ )} + + {historySql && ( +
+
+ + {picked && ( + + )} +
+ {typeof history === 'object' && !Array.isArray(history) && ( +

+ {history.error} +

+ )} + {Array.isArray(history) && + (history.length === 0 ? ( +

+ The server has no backups of this database on record. +

+ ) : ( +
    + {history.map((entry, i) => { + const chosen = picked?.restoreKey === entry.restoreKey; + return ( +
  • + {entry.finishedAt} + + {entry.location} + + {formatSize(entry.sizeBytes)} + +
  • + ); + })} +
+ ))} +
+ )} +
+ ); +}; diff --git a/docs/testing/TEST_IDS.md b/docs/testing/TEST_IDS.md index 60c7af7c9..b6099c647 100644 --- a/docs/testing/TEST_IDS.md +++ b/docs/testing/TEST_IDS.md @@ -8,16 +8,15 @@ or `TestIds` for the same tree with autocomplete. `{name}` marks a part filled i time; "in FilterPicker" marks an ID a shared component draws from the prop written here. How to write a test with them: [WRITING_E2E.md](WRITING_E2E.md). -Every control has a test ID: **933** buttons, text boxes, selects and textareas. +Every control has a test ID: **939** buttons, text boxes, selects and textareas. ## access - **AccessGrantsStage** · `features/access/components/AccessGrantsStage.tsx` + - `access-grants-also-holds` · p - `access-grants-change-{action}` · li - `access-grants-copy` · button · Copy - `access-grants-diff` · ul - - `access-grants-mode` · div in Segmented - - `access-grants-mode-{value}` · button in Segmented - `access-grants-open-sql` · button · Open in SQL Editor - `access-grants-preset-{p}` · button - `access-grants-presets` · div @@ -1427,10 +1426,25 @@ Every control has a test ID: **933** buttons, text boxes, selects and textareas. - `backup-scope-{value}` · button in Segmented - `backup-tables` · textarea · orders, customers - `backup-unsupported` · div in EmptyState · No backup commands - - `restore-command` · section in CommandBlock · Restore - - `restore-command-copy` · button in CommandBlock · Restore + - `restore-command` · section in CommandBlock + - `restore-command-copy` · button in CommandBlock - `restore-command-open-sql` · button in CommandBlock · Open in SQL Editor - - `restore-command-text` · pre in CommandBlock · Restore + - `restore-command-text` · pre in CommandBlock +- **BackupServerActions** · `features/utilities/components/BackupServerActions.tsx` + - `backup-history` · ul + - `backup-history-clear-pick` · button · Restore the newest instead + - `backup-history-empty` · p + - `backup-history-error` · p + - `backup-history-load` · button + - `backup-history-pick-{i}` · button + - `backup-history-row-{i}` · li + - `backup-run` · button · Run backup now + - `backup-run-cancel` · button · Cancel + - `backup-run-confirm` · button · Run it + - `backup-run-confirm-box` · div + - `backup-run-error` · p + - `backup-run-status` · span + - `backup-server-actions` · section - **CloneTableModal** · `features/utilities/components/CloneTableModal.tsx` - `clone-apply` · button · Apply clone - `clone-archive-preview` · p diff --git a/packages/sql/src/index.ts b/packages/sql/src/index.ts index 6f4958edf..899a88025 100644 --- a/packages/sql/src/index.ts +++ b/packages/sql/src/index.ts @@ -197,6 +197,11 @@ export { backupFileName, parseTableList, buildBackupCommands, + backupHistoryQuery, + normalizeBackupHistory, +} from './modules/utilities/backup.js'; +export type { + BackupHistoryEntry, } from './modules/utilities/backup.js'; export type { BackupCommands, @@ -478,6 +483,8 @@ export { compileGridChanges, gridObjectKey, heldGridPermissions, + splitHeldPrivileges, + describeHeldPrivilege, expandToInstance, accessStatementPlace, qualifyDatabaseSql, diff --git a/packages/sql/src/modules/access/db-access.test.ts b/packages/sql/src/modules/access/db-access.test.ts index c6d962a8b..8ff6bf41a 100644 --- a/packages/sql/src/modules/access/db-access.test.ts +++ b/packages/sql/src/modules/access/db-access.test.ts @@ -703,3 +703,54 @@ describe('role membership WITH ADMIN OPTION', () => { expect(grant('sqlserver')).toEqual({ sql: 'ALTER ROLE [reader] ADD MEMBER [alice];' }); }); }); + +describe('routine privileges', () => { + const sqlOf = (dialect: string) => buildDbAccessPrivilegeQueries({ dialect }).map((q) => q.sql).join('\n'); + + it('reads EXECUTE on procedures and functions where each engine records it', () => { + expect(sqlOf('postgres')).toContain('information_schema.role_routine_grants'); + // Only the rung that may read mysql.* (the first) can see procs_priv. + expect(buildDbAccessPrivilegeQueries({ dialect: 'mysql' })[0]!.sql).toContain('mysql.procs_priv'); + expect(sqlOf('db2')).toContain('SYSCAT.ROUTINEAUTH'); + }); + + it('keeps the routine kind, which the revoke needs', () => { + const [p, f, r] = normalizeDbPrivileges([ + { grantee: 'app', privilege: 'EXECUTE', object_type: 'PROCEDURE', object_schema: 'shop', object_name: 'close_day' }, + { grantee: 'app', privilege: 'EXECUTE', object_type: 'FUNCTION', object_schema: 'shop', object_name: 'tax' }, + { grantee: 'app', privilege: 'EXECUTE', object_type: 'ROUTINE', object_schema: 'shop', object_name: 'tax' }, + ]); + expect([p!.objectType, f!.objectType, r!.objectType]).toEqual(['PROCEDURE', 'FUNCTION', 'ROUTINE']); + }); + + const revoke = (dialect: string, objectType: string) => { + const r = buildGrantRevokeSql({ + dialect, + action: 'revoke', + privilege: 'EXECUTE', + objectType, + objectSchema: 'shop', + objectName: 'tax', + grantee: 'app', + granteeKind: 'user', + } as never); + return 'error' in r ? `error: ${r.error}` : r.sql; + }; + + it('revokes a routine grant with each engine’s own syntax', () => { + expect(revoke('postgres', 'ROUTINE')).toBe('REVOKE EXECUTE ON ROUTINE "shop"."tax" FROM "app";'); + expect(revoke('postgres', 'FUNCTION')).toMatch(/^REVOKE EXECUTE ON FUNCTION "shop"\."tax" FROM/); + expect(revoke('db2', 'PROCEDURE')).toMatch(/^REVOKE EXECUTE ON PROCEDURE "shop"\."tax" FROM USER/); + expect(revoke('mysql', 'FUNCTION')).toMatch(/^REVOKE EXECUTE ON FUNCTION `shop`\.`tax` FROM 'app'@'%';$/); + expect(revoke('sqlserver', 'PROCEDURE')).toMatch(/ON OBJECT::\[shop\]\.\[tax\]/); + expect(revoke('mysql', 'ROUTINE')).toMatch(/^error: /); + expect(revoke('clickhouse', 'FUNCTION')).toMatch(/^error: /); + }); + + it('names an Oracle object with no keyword, as Oracle requires', () => { + expect(revoke('oracle', 'PROCEDURE')).toMatch(/^REVOKE EXECUTE ON "?shop"?\."?tax"? FROM /i); + for (const type of ['TABLE', 'PROCEDURE', 'FUNCTION']) { + expect(revoke('oracle', type)).not.toMatch(/ON (TABLE|PROCEDURE|FUNCTION|ROUTINE) /); + } + }); +}); diff --git a/packages/sql/src/modules/access/db-access.ts b/packages/sql/src/modules/access/db-access.ts index fa0a8c11e..3c4534d53 100644 --- a/packages/sql/src/modules/access/db-access.ts +++ b/packages/sql/src/modules/access/db-access.ts @@ -61,6 +61,12 @@ export type DbPrivilegeObjectType = | 'ROLE' | 'SYSTEM' | 'COLUMN' + /** EXECUTE (and kin) on a procedure. */ + | 'PROCEDURE' + /** EXECUTE (and kin) on a function. */ + | 'FUNCTION' + /** A procedure or a function, where the catalog does not say which (Postgres). */ + | 'ROUTINE' | 'OTHER'; export interface DbPrivilege { @@ -631,6 +637,14 @@ export function buildGrantRevokeSql(args: DbAccessGrantArgs): { sql: string } | return emitGrant({ action, privilege: privSql, on, grantee: granteeSql, grantOption }); } + const routine = objectType === 'PROCEDURE' || objectType === 'FUNCTION' || objectType === 'ROUTINE'; + if (routine && fam === 'clickhouse') return { error: 'ClickHouse has no procedures or functions to grant on.' }; + if (routine && (fam === 'mysql' || fam === 'mariadb')) { + // MySQL says which: EXECUTE ON PROCEDURE db.p or ON FUNCTION db.f. + if (objectType === 'ROUTINE') return { error: 'Say whether this is a procedure or a function.' }; + return emitGrant({ action, privilege: privSql, on: `ON ${objectType} ${objectSql}`, grantee: granteeSql, grantOption }); + } + if (fam === 'mysql' || fam === 'mariadb' || fam === 'clickhouse') { // These name the whole server `*.*` and a whole database `db`.* — a bare // `db` is a table reference, so a database-level grant landed on a table @@ -650,8 +664,19 @@ export function buildGrantRevokeSql(args: DbAccessGrantArgs): { sql: string } | }); } - // Postgres, Oracle and Db2 share the keyword form; Db2 differs only in that - // a database authority is granted ON DATABASE with no object named. + // Oracle names an object with no keyword: GRANT SELECT ON hr.orders. + // `ON TABLE` is Postgres and Db2 syntax, and Oracle rejects it. + if (fam === 'oracle' && objectType !== 'SYSTEM' && objectType !== 'SCHEMA' && objectType !== 'DATABASE') { + return emitGrant({ action, privilege: privSql, on: `ON ${objectSql}`, grantee: granteeSql, grantOption }); + } + if (routine && fam === 'db2' && objectType === 'ROUTINE') { + return { error: 'Say whether this is a procedure or a function.' }; + } + + // Postgres and Db2 share the keyword form; Db2 differs only in that a + // database authority is granted ON DATABASE with no object named. A routine + // is ON PROCEDURE / ON FUNCTION, or ON ROUTINE on Postgres when the catalog + // did not say which. const on = objectType === 'SYSTEM' ? '' @@ -661,7 +686,9 @@ export function buildGrantRevokeSql(args: DbAccessGrantArgs): { sql: string } | ? fam === 'db2' ? 'ON DATABASE' : `ON DATABASE ${namedObject()}` - : `ON TABLE ${objectSql}`; + : routine + ? `ON ${objectType} ${objectSql}` + : `ON TABLE ${objectSql}`; return emitGrant({ action, privilege: privSql, on, grantee: granteeSql, grantOption }); } @@ -712,6 +739,9 @@ function normalizeObjectType(raw: unknown): DbPrivilegeObjectType { if (s.includes('DATABASE')) return 'DATABASE'; if (s.includes('ROLE')) return 'ROLE'; if (s.includes('SYSTEM') || s.includes('SERVER')) return 'SYSTEM'; + if (s.includes('PROCEDURE')) return 'PROCEDURE'; + if (s.includes('FUNCTION')) return 'FUNCTION'; + if (s.includes('ROUTINE')) return 'ROUTINE'; if (s.includes('TABLE') || s.includes('OBJECT') || s.includes('VIEW') || s.includes('OBJECT_OR_COLUMN')) { return 'TABLE'; } @@ -821,6 +851,22 @@ ORDER BY CASE WHEN r.rolcanlogin THEN 1 ELSE 0 END, r.rolname * foreign servers), so `GRANT ALL ON SCHEMA` and `GRANT ALL ON DATABASE` both * read as nothing. A NULL ACL is the owner-only default and yields no rows. */ +/** + * EXECUTE on functions and procedures, so a routine row in the Grants grid + * opens ticked when it is held. Grants to PUBLIC are listed under PUBLIC, not + * under each role, so the default EXECUTE every function carries is not read + * as something a role was given. + */ +const PG_ROUTINE_GRANTS = `SELECT grantee, + privilege_type, + 'ROUTINE', + routine_schema, + routine_name, + CASE WHEN is_grantable = 'YES' THEN 1 ELSE 0 END, + grantor +FROM information_schema.role_routine_grants +WHERE routine_schema NOT IN ('pg_catalog', 'information_schema')`; + const PG_PRIVILEGES = ` SELECT grantee, privilege_type AS privilege, @@ -854,6 +900,8 @@ FROM pg_database d CROSS JOIN LATERAL aclexplode(d.datacl) a WHERE d.datname = current_database() UNION ALL +${PG_ROUTINE_GRANTS} +UNION ALL SELECT m.rolname, g.rolname, 'ROLE', @@ -888,6 +936,8 @@ SELECT grantee, FROM information_schema.usage_privileges WHERE object_type = 'SCHEMA' UNION ALL +${PG_ROUTINE_GRANTS} +UNION ALL SELECT m.rolname, g.rolname, 'ROLE', @@ -1007,6 +1057,19 @@ FROM information_schema.SCHEMA_PRIVILEGES${inSchema}`, NULL FROM information_schema.USER_PRIVILEGES WHERE PRIVILEGE_TYPE <> 'USAGE'`); + } + if (opts.roles) { + // EXECUTE on a procedure or function. Only mysql.procs_priv records it, + // so only the rungs that may read mysql.* include it. + parts.push(`SELECT CONCAT('''', p.User, '''@''', p.Host, ''''), + 'EXECUTE', + IF(p.Routine_type = 'FUNCTION', 'FUNCTION', 'PROCEDURE'), + p.Db, + p.Routine_name, + CASE WHEN FIND_IN_SET('Grant', p.Proc_priv) > 0 THEN 1 ELSE 0 END, + NULL +FROM mysql.procs_priv p +WHERE FIND_IN_SET('Execute', p.Proc_priv) > 0`); } if (opts.roles === 'mysql') { // Quoted the way information_schema quotes a grantee, so one normalizer @@ -1267,6 +1330,14 @@ SELECT TRIM(GRANTEE), 'DELETE', 'TABLE', TRIM(TABSCHEMA), TRIM(TABNAME), CASE WHEN DELETEAUTH = 'G' THEN 1 ELSE 0 END, TRIM(GRANTOR) FROM SYSCAT.TABAUTH WHERE DELETEAUTH IN ('Y', 'G') UNION ALL +SELECT TRIM(A.GRANTEE), 'EXECUTE', + CASE WHEN R.ROUTINETYPE = 'P' THEN 'PROCEDURE' ELSE 'FUNCTION' END, + TRIM(A.SCHEMA), TRIM(R.ROUTINENAME), + CASE WHEN A.EXECUTEAUTH = 'G' THEN 1 ELSE 0 END, TRIM(A.GRANTOR) +FROM SYSCAT.ROUTINEAUTH A +JOIN SYSCAT.ROUTINES R ON R.ROUTINESCHEMA = A.SCHEMA AND R.SPECIFICNAME = A.SPECIFICNAME +WHERE A.EXECUTEAUTH IN ('Y', 'G') AND A.SCHEMA NOT LIKE 'SYS%' +UNION ALL SELECT TRIM(GRANTEE), TRIM(ROLENAME), 'ROLE', NULL, TRIM(ROLENAME), 0, NULL FROM SYSCAT.ROLEAUTH WHERE ROLENAME NOT LIKE 'SYS%' diff --git a/packages/sql/src/modules/access/object-grid.held.test.ts b/packages/sql/src/modules/access/object-grid.held.test.ts index b4d7dcd3a..48df348bc 100644 --- a/packages/sql/src/modules/access/object-grid.held.test.ts +++ b/packages/sql/src/modules/access/object-grid.held.test.ts @@ -13,7 +13,14 @@ import { describe, expect, it } from 'vitest'; import { buildAccessSql } from './access-sql'; import type { DbPrivilege } from './db-access'; -import { compileGridChanges, gridObjectKey, heldGridPermissions, type GridRow } from './object-grid'; +import { + compileGridChanges, + describeHeldPrivilege, + gridObjectKey, + heldGridPermissions, + splitHeldPrivileges, + type GridRow, +} from './object-grid'; const priv = (privilege: string, objectSchema: string | null, objectName: string | null, extra: Partial = {}): DbPrivilege => ({ grantee: 'reporting', @@ -127,3 +134,71 @@ describe('compileGridChanges', () => { expect(compileGridChanges(rows, held, options).revoke).toEqual([]); }); }); + +describe('splitHeldPrivileges', () => { + const objects = [{ schema: 'public', kind: 'table' as const, name: 'orders' }]; + const priv = (over: Partial): DbPrivilege => ({ + grantee: 'report_user', + privilege: 'SELECT', + objectType: 'TABLE', + objectSchema: 'public', + objectName: 'orders', + grantable: false, + grantor: null, + state: 'grant', + ...over, + }); + + it('lists what the grid cannot show, and keeps what it can as held', () => { + const { held, elsewhere } = splitHeldPrivileges( + [ + priv({}), + priv({ privilege: 'CONNECT', objectType: 'DATABASE', objectSchema: null, objectName: 'shop' }), + priv({ privilege: 'USAGE', objectType: 'SCHEMA', objectSchema: null, objectName: 'public' }), + priv({ privilege: 'SELECT', objectName: 'archive' }), + priv({ privilege: 'DELETE', state: 'deny' }), + ], + objects, + 'postgres', + 'public' + ); + expect(held.get(gridObjectKey(objects[0]!))).toEqual(['read']); + expect(elsewhere.map(describeHeldPrivilege)).toEqual([ + 'CONNECT on database shop', + 'USAGE on schema public', + 'SELECT on public.archive', + 'DENY DELETE on public.orders', + ]); + }); + + it('agrees with heldGridPermissions on what is held', () => { + const privileges = [priv({}), priv({ privilege: 'INSERT' })]; + expect(splitHeldPrivileges(privileges, objects, 'postgres', 'public').held).toEqual( + heldGridPermissions(privileges, objects, 'postgres', 'public') + ); + }); +}); + +describe('routine rows open ticked', () => { + it('reads a held EXECUTE onto the function’s row', () => { + const objects = [{ schema: 'shop', kind: 'function' as const, name: 'tax' }]; + const held = heldGridPermissions( + [ + { + grantee: 'app', + privilege: 'EXECUTE', + objectType: 'FUNCTION', + objectSchema: 'shop', + objectName: 'tax', + grantable: false, + grantor: null, + state: 'grant', + }, + ], + objects, + 'postgres', + 'shop' + ); + expect(held.get(gridObjectKey(objects[0]!))).toEqual(['execute-function']); + }); +}); diff --git a/packages/sql/src/modules/access/object-grid.ts b/packages/sql/src/modules/access/object-grid.ts index 0cfd11860..f332971ce 100644 --- a/packages/sql/src/modules/access/object-grid.ts +++ b/packages/sql/src/modules/access/object-grid.ts @@ -362,6 +362,24 @@ export function heldGridPermissions( dialect: string, fallbackSchema = '' ): Map { + return splitHeldPrivileges(privileges, objects, dialect, fallbackSchema).held; +} + +/** + * What a principal holds, split into what the grid shows and everything else. + * + * `held` is `heldGridPermissions`. `elsewhere` is each privilege row the grid + * cannot show: database-, schema- and system-wide grants, DENY rows, grants on + * an object that is not a row, and privileges no column can show. A grid that + * opens on what is held still let a reader conclude that nothing else was; + * listing these under it says otherwise. + */ +export function splitHeldPrivileges( + privileges: readonly DbPrivilege[], + objects: readonly { schema?: string | null; kind: GridObjectKind; name: string }[], + dialect: string, + fallbackSchema = '' +): { held: Map; elsewhere: DbPrivilege[] } { // A privilege row names schema and object; the grid knows the kind. Rows // whose schema is not reported (MySQL's by-name grants) match on name alone. const byName = new Map(); @@ -370,8 +388,12 @@ export function heldGridPermissions( byName.set(n, [...(byName.get(n) ?? []), { ...o, schema: o.schema?.trim() || fallbackSchema }]); } const held = new Map(); + const elsewhere: DbPrivilege[] = []; for (const p of privileges) { - if (p.state === 'deny' || !p.objectName) continue; + if (p.state === 'deny' || !p.objectName) { + elsewhere.push(p); + continue; + } const privilege = (p.privilege || '').trim().toUpperCase(); const schema = (p.objectSchema ?? '').trim().toLowerCase(); // Either side may not know the schema: MySQL-family grants name the @@ -381,6 +403,7 @@ export function heldGridPermissions( const own = (o.schema ?? '').trim().toLowerCase(); return !schema || !own || own === schema; }); + let shown = false; for (const o of candidates) { const cells = prunedPermissions( dialect, @@ -388,11 +411,32 @@ export function heldGridPermissions( GRID_COLUMNS[o.kind].filter((c) => EVERY_PRIVILEGE.has(privilege) || CELL_PRIVILEGE[c] === privilege) ); if (cells.length === 0) continue; + shown = true; const key = gridObjectKey(o); held.set(key, [...new Set([...(held.get(key) ?? []), ...cells])]); } + if (!shown) elsewhere.push(p); + } + return { held, elsewhere }; +} + +/** One held privilege the grid does not show, in a few words: "CONNECT on database shop". */ +export function describeHeldPrivilege(p: DbPrivilege): string { + const verb = `${p.state === 'deny' ? 'DENY ' : ''}${(p.privilege || '').trim().toUpperCase()}`; + const object = [p.objectSchema, p.objectName].filter(Boolean).join('.'); + switch (p.objectType) { + case 'DATABASE': + return object ? `${verb} on database ${object}` : `${verb} on the database`; + case 'SCHEMA': + return `${verb} on schema ${p.objectName ?? p.objectSchema ?? ''}`.trim(); + case 'GLOBAL': + case 'SYSTEM': + return `${verb} (server-wide)`; + case 'ROLE': + return `member of ${p.objectName ?? p.privilege}`; + default: + return object ? `${verb} on ${object}` : verb; } - return held; } /** What a grid edit changes: GRANTs for new ticks, REVOKEs for held ones cleared. */ diff --git a/packages/sql/src/modules/utilities/backup.test.ts b/packages/sql/src/modules/utilities/backup.test.ts index a601a3b05..8743a351a 100644 --- a/packages/sql/src/modules/utilities/backup.test.ts +++ b/packages/sql/src/modules/utilities/backup.test.ts @@ -7,9 +7,11 @@ import { describe, expect, it } from 'vitest'; import { DIALECTS } from '../../providers/provider-settings.js'; import { backupFileName, + backupHistoryQuery, backupSupport, buildBackupCommands, defaultBackupSettings, + normalizeBackupHistory, normalizeBackupSettings, parseTableList, } from './backup.js'; @@ -275,3 +277,55 @@ describe('file engines, Redshift, MongoDB, Redis', () => { expect(c.restore.split('\n').every((l) => l.startsWith('#'))).toBe(true); }); }); + +describe('backups the server recorded', () => { + const conn = (dialect: string, database = "O'Brien") => ({ dialect, host: 'db', database, username: 'app' }); + + it('lists them where the engine keeps a record, and says so where it keeps none', () => { + const mssql = backupHistoryQuery(conn('sqlserver'))!; + expect(mssql).toContain('msdb.dbo.backupset'); + expect(mssql).toContain("N'O''Brien'"); + expect(backupHistoryQuery(conn('db2', 'SAMPLE'))).toContain('SYSIBMADM.DB_HISTORY'); + expect(backupHistoryQuery(conn('clickhouse', 'shop'))).toContain('system.backups'); + // A dump file on the client is just a file; nothing records it. + expect(backupHistoryQuery(conn('postgres', 'shop'))).toBeNull(); + }); + + const restoreOf = (dialect: string, restoreFrom: string, database = 'shop') => { + const built = buildBackupCommands(conn(dialect, database), { restoreFrom }, new Date('2026-10-06T10:00:00Z')); + if ('error' in built) throw new Error(built.error); + return built; + }; + + it('restores the backup picked from that list', () => { + expect(restoreOf('sqlserver', "/var/opt/mssql/backups/it's.bak").restore).toContain( + "FROM DISK = N'/var/opt/mssql/backups/it''s.bak'" + ); + const db2 = restoreOf('db2', '20261004093000', 'SAMPLE'); + expect(db2.restore).toContain('TAKEN AT 20261004093000'); + expect(db2.notes.join(' ')).toContain('taken at 20261004093000'); + expect(restoreOf('clickhouse', "Disk('backups', 'shop_old.zip')").restore).toContain( + "FROM Disk('backups', 'shop_old.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"); + expect(ch.restore).not.toContain('DROP DATABASE'); + expect(ch.restore).toContain("FROM Disk('backups'"); + }); + + it('reads rows in any column case and drops ones it cannot restore', () => { + expect( + normalizeBackupHistory([ + { FINISHED_AT: '20261004093000', LOCATION: '/backups', SIZE_BYTES: null, RESTORE_KEY: '20261004093000' }, + { finished_at: new Date('2026-10-05T01:02:03Z'), location: '/x.bak', size_bytes: '2048', restore_key: '/x.bak' }, + { finished_at: 'later', location: '?', size_bytes: 1, restore_key: '' }, + ]) + ).toEqual([ + { finishedAt: '20261004093000', location: '/backups', sizeBytes: null, restoreKey: '20261004093000' }, + { finishedAt: '2026-10-05T01:02:03.000Z', location: '/x.bak', sizeBytes: 2048, restoreKey: '/x.bak' }, + ]); + }); +}); diff --git a/packages/sql/src/modules/utilities/backup.ts b/packages/sql/src/modules/utilities/backup.ts index c11eca452..6462bbd64 100644 --- a/packages/sql/src/modules/utilities/backup.ts +++ b/packages/sql/src/modules/utilities/backup.ts @@ -44,6 +44,40 @@ export function backupSupport(dialect: string): Omit | u return support; } +/** One backup the engine recorded, as `BackupDialect.history` reports it. */ +export interface BackupHistoryEntry { + finishedAt: string; + location: string; + sizeBytes: number | null; + /** What `restoreFrom` takes to restore this backup. */ + restoreKey: string; +} + +/** The query listing this connection's recorded backups, or null where the engine keeps none. */ +export function backupHistoryQuery(conn: BackupConnection): string | null { + return resolveBackup(conn.dialect)?.history?.(conn) ?? null; +} + +/** History rows in any column case (Db2 answers in capitals), newest first as queried. */ +export function normalizeBackupHistory(rows: readonly Record[]): BackupHistoryEntry[] { + const field = (row: Record, name: string): unknown => { + const key = Object.keys(row).find((k) => k.toLowerCase() === name); + return key === undefined ? undefined : row[key]; + }; + const text = (v: unknown) => (v instanceof Date ? v.toISOString() : v == null ? '' : String(v).trim()); + return rows + .map((row) => { + const size = Number(field(row, 'size_bytes')); + return { + finishedAt: text(field(row, 'finished_at')), + location: text(field(row, 'location')), + sizeBytes: field(row, 'size_bytes') == null || !Number.isFinite(size) ? null : size, + restoreKey: text(field(row, 'restore_key')), + }; + }) + .filter((e) => e.restoreKey); +} + /** A first-time reader's settings for an engine. */ export function defaultBackupSettings(dialect: string): BackupSettings { const support = resolveBackup(dialect); @@ -104,7 +138,7 @@ export function parseTableList(text: string): string[] { */ export function buildBackupCommands( conn: BackupConnection, - settings: Partial & { fileName?: string; tables?: readonly string[] }, + settings: Partial & { fileName?: string; tables?: readonly string[]; restoreFrom?: string }, now: Date = new Date() ): BackupCommands | { error: string } { const support = resolveBackup(conn.dialect); @@ -116,6 +150,7 @@ export function buildBackupCommands( ...settled, fileName: settings.fileName?.trim() || backupFileName(conn.database, now), tables: support.tables ? [...(settings.tables ?? [])].map((t) => t.trim()).filter(Boolean) : [], + restoreFrom: settings.restoreFrom?.trim() || undefined, }; return support.build(conn, request); } diff --git a/packages/sql/src/modules/utilities/backup.types.ts b/packages/sql/src/modules/utilities/backup.types.ts index 51a31549e..d55fbbc60 100644 --- a/packages/sql/src/modules/utilities/backup.types.ts +++ b/packages/sql/src/modules/utilities/backup.types.ts @@ -60,6 +60,12 @@ export interface BackupRequest extends BackupSettings { fileName: string; /** Only these tables. Empty: everything in scope. */ tables: readonly string[]; + /** + * A backup picked from the engine's history (its `restore_key`), for the + * restore to read instead of the newest one in the folder. Each engine + * checks it and quotes it; one it cannot read leaves the restore as it was. + */ + restoreFrom?: string; } /** What "folder" means on an engine, so the field can say so. */ @@ -96,5 +102,12 @@ export interface BackupDialect { schemaLimit: boolean; /** How the tool gets the password, since the command never contains it. */ passwordNote: string; + /** + * The backups this engine has recorded, newest first, as one read-only query. + * Columns: `finished_at`, `location`, `size_bytes` (may be null) and + * `restore_key`, the value `BackupRequest.restoreFrom` takes. Absent where + * the engine keeps no record (a dump file on the client is just a file). + */ + history?(conn: BackupConnection): string; build(conn: BackupConnection, request: BackupRequest): BackupCommands; } diff --git a/packages/sql/src/providers/clickHouse/clickhouse.backup.ts b/packages/sql/src/providers/clickHouse/clickhouse.backup.ts index 9cf56922c..bfa715198 100644 --- a/packages/sql/src/providers/clickHouse/clickhouse.backup.ts +++ b/packages/sql/src/providers/clickHouse/clickhouse.backup.ts @@ -14,10 +14,19 @@ 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 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; +} + 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)})`; + // 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 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}`; @@ -28,7 +37,7 @@ function build(conn: BackupConnection, req: BackupRequest): BackupCommands { return { language: 'sql', backup: `BACKUP ${what} TO ${target};`, - restore: `${req.tables.length > 0 ? `CREATE DATABASE IF NOT EXISTS ${restored};\n` : ''}RESTORE ${restoreWhat} FROM ${target};`, + restore: `${req.tables.length > 0 ? `CREATE DATABASE IF NOT EXISTS ${restored};\n` : ''}RESTORE ${restoreWhat} FROM ${restoreTarget};`, location: `${disk}:${file}`, notes: [ `The disk ${disk} must be listed under in the server configuration.`, @@ -51,5 +60,11 @@ export const clickHouseBackup: BackupDialect = { tables: true, schemaLimit: false, passwordNote: 'Runs as SQL on this connection; the user needs the BACKUP privilege.', + // Kept in memory by the server: backups since it last started. + history: () => `SELECT end_time AS finished_at, name AS location, total_size AS size_bytes, name AS restore_key +FROM system.backups +WHERE status = 'BACKUP_CREATED' +ORDER BY end_time DESC +LIMIT 20`, build, }; diff --git a/packages/sql/src/providers/db2/db2.backup.ts b/packages/sql/src/providers/db2/db2.backup.ts index 43ae58bf5..9c58e87e7 100644 --- a/packages/sql/src/providers/db2/db2.backup.ts +++ b/packages/sql/src/providers/db2/db2.backup.ts @@ -16,6 +16,8 @@ import { joinPath, shellArg } from '../../modules/utilities/backup-helpers.js'; function build(conn: BackupConnection, req: BackupRequest): BackupCommands { const db = shellArg(conn.database.toUpperCase()); + // An image picked from DB_HISTORY: Db2 names it by its 14-digit timestamp. + const takenAt = req.restoreFrom && /^\d{14}$/.test(req.restoreFrom) ? req.restoreFrom : null; const folder = req.folder.trim() || '/database/backups'; if (req.scope === 'schema') { const schema = (conn.schema || conn.username || '').toUpperCase(); @@ -31,11 +33,13 @@ function build(conn: BackupConnection, req: BackupRequest): BackupCommands { return { language: 'shell', backup: `db2 BACKUP DATABASE ${db} TO ${shellArg(folder)}${req.compress ? ' COMPRESS' : ''} WITHOUT PROMPTING`, - restore: `db2 RESTORE DATABASE ${db} FROM ${shellArg(folder)} REPLACE EXISTING WITHOUT PROMPTING`, + restore: `db2 RESTORE DATABASE ${db} FROM ${shellArg(folder)}${takenAt ? ` TAKEN AT ${takenAt}` : ''} REPLACE EXISTING WITHOUT PROMPTING`, location: folder, notes: [ 'An offline backup fails while anything is connected: db2 force application all, or add ONLINE once archive logging is on.', - `With more than one image in the folder, add TAKEN AT to the restore; db2 list history backup all for ${db} shows them.`, + takenAt + ? `The restore reads the image taken at ${takenAt}.` + : `With more than one image in the folder, pick one under Backups on this server, or add TAKEN AT to the restore.`, 'REPLACE EXISTING overwrites the database.', ], }; @@ -55,5 +59,12 @@ export const db2Backup: BackupDialect = { tables: false, schemaLimit: false, passwordNote: 'Runs as the instance owner on the server, who needs no password there.', + // Read with SQL on this connection; the restore itself is a CLP command. + history: () => `SELECT START_TIME AS finished_at, LOCATION AS location, CAST(NULL AS BIGINT) AS size_bytes, + START_TIME AS restore_key +FROM SYSIBMADM.DB_HISTORY +WHERE OPERATION = 'B' AND OBJECTTYPE = 'D' AND COALESCE(SQLCODE, 0) >= 0 +ORDER BY START_TIME DESC +FETCH FIRST 20 ROWS ONLY`, build, }; diff --git a/packages/sql/src/providers/sqlServer/sqlserver.backup.ts b/packages/sql/src/providers/sqlServer/sqlserver.backup.ts index 7e2c0c870..69f32e3b1 100644 --- a/packages/sql/src/providers/sqlServer/sqlserver.backup.ts +++ b/packages/sql/src/providers/sqlServer/sqlserver.backup.ts @@ -18,6 +18,8 @@ const nString = (value: string) => `N'${value.replace(/'/g, "''")}'`; function build(conn: BackupConnection, req: BackupRequest): BackupCommands { const db = bracket(conn.database); const location = joinPath(req.folder, `${req.fileName}.bak`); + // A file picked from msdb's history; any path is quoted as a string. + const restoreFile = req.restoreFrom || location; const options = ['COPY_ONLY', 'INIT', 'CHECKSUM', ...(req.compress ? ['COMPRESSION'] : []), 'STATS = 10']; return { language: 'sql', @@ -25,7 +27,7 @@ function build(conn: BackupConnection, req: BackupRequest): BackupCommands { restore: [ 'USE [master];', `ALTER DATABASE ${db} SET SINGLE_USER WITH ROLLBACK IMMEDIATE;`, - `RESTORE DATABASE ${db}\n FROM DISK = ${nString(location)}\n WITH REPLACE, RECOVERY, STATS = 10;`, + `RESTORE DATABASE ${db}\n FROM DISK = ${nString(restoreFile)}\n WITH REPLACE, RECOVERY, STATS = 10;`, `ALTER DATABASE ${db} SET MULTI_USER;`, ].join('\n'), location, @@ -50,5 +52,15 @@ export const sqlServerBackup: BackupDialect = { tables: false, schemaLimit: false, passwordNote: 'Runs as SQL on this connection; the login needs BACKUP DATABASE (db_backupoperator) and, to restore, dbcreator.', + // msdb records every backup the server took, by file. + history: (conn) => `SELECT TOP (20) + b.backup_finish_date AS finished_at, + m.physical_device_name AS location, + b.backup_size AS size_bytes, + m.physical_device_name AS restore_key +FROM msdb.dbo.backupset b +JOIN msdb.dbo.backupmediafamily m ON m.media_set_id = b.media_set_id +WHERE b.database_name = ${nString(conn.database)} AND b.type = 'D' +ORDER BY b.backup_finish_date DESC;`, build, };