Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 138 additions & 54 deletions apps/studio/components/interfaces/SQLEditor/RunQueryWarningModal.tsx
Original file line number Diff line number Diff line change
@@ -1,77 +1,161 @@
import { DialogSectionSeparator, Separator } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import {
Button,
cn,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
} from 'ui'
import { Admonition } from 'ui-patterns'

import { PotentialIssues } from './SQLEditor.types'
import { DOCS_URL } from '@/lib/constants'

interface RunQueryWarningModalProps {
visible: boolean
potentialIssues: PotentialIssues | undefined
onCancel: () => void
onConfirm: () => void
onConfirmWithRLS?: () => void
}

export const RunQueryWarningModal = ({
visible,
potentialIssues,
onCancel,
onConfirm,
onConfirmWithRLS,
}: RunQueryWarningModalProps) => {
const { hasDestructiveOperations, hasUpdateWithoutWhere, hasAlterDatabasePreventConnection } =
potentialIssues || {}
const {
hasDestructiveOperations,
hasUpdateWithoutWhere,
hasAlterDatabasePreventConnection,
createTablesMissingRLS,
} = potentialIssues || {}

const missingRLSTables = createTablesMissingRLS ?? []
const hasMissingRLS = missingRLSTables.length > 0
const issueCount =
(hasDestructiveOperations ? 1 : 0) +
(hasUpdateWithoutWhere ? 1 : 0) +
(hasAlterDatabasePreventConnection ? 1 : 0) +
(hasMissingRLS ? 1 : 0)

return (
<ConfirmationModal
visible={visible}
size="large"
title={`Potential issue${hasDestructiveOperations && hasUpdateWithoutWhere ? 's' : ''} detected with your query`}
confirmLabel="Run this query"
variant="warning"
alert={{
base: {
variant: 'warning',
},
title:
hasDestructiveOperations && hasUpdateWithoutWhere
? 'The following potential issues have been detected:'
: 'The following potential issue has been detected:',
description: 'Ensure that these are intentional before executing this query',
<Dialog
open={visible}
onOpenChange={(open) => {
if (!open) onCancel()
}}
onCancel={onCancel}
onConfirm={onConfirm}
>
<div className="text-sm">
<ul className="border rounded-md grid bg-surface-200 divide-y">
{hasDestructiveOperations && (
<li className="grid pt-3 pb-2 px-4">
<span className="font-bold">Query has destructive operations</span>
<span className="text-foreground-light">
Make sure you are not accidentally removing something important.
</span>
</li>
)}
{hasUpdateWithoutWhere && (
<li className="grid pt-2 pb-3 px-4 gap-1">
<span className="font-bold">Query uses update without a where clause</span>
<span className="text-foreground-light">
Without a <code className="text-code-inline">where</code> clause, this could update
all rows in the table.
</span>
</li>
)}
{hasAlterDatabasePreventConnection && (
<li className="grid pt-2 pb-3 px-4 gap-1">
<span className="font-bold">Query will prevent connections to your database</span>
<span className="text-foreground-light">
The dashboard will no longer have access to your database, and you will need a
direct connection to your database to reconfigure this setting
</span>
</li>
<DialogContent aria-describedby={undefined} className="p-0 gap-0 pb-5 !block" size="large">
<DialogHeader className={cn('border-b')} padding="small">
<DialogTitle>
{`Potential issue${issueCount > 1 ? 's' : ''} detected with your query`}
</DialogTitle>
<DialogDescription className="sr-only">
Review the warnings below before running this query.
</DialogDescription>
</DialogHeader>

<Admonition
type="warning"
label={
issueCount > 1
? 'The following potential issues have been detected:'
: 'The following potential issue has been detected:'
}
description="Ensure that these are intentional before executing this query"
className="border-x-0 rounded-none -mt-px"
/>

<DialogSection padding="small">
<div className="text-sm">
<ul className="border rounded-md grid bg-surface-200 divide-y">
{hasDestructiveOperations && (
<li className="grid pt-3 pb-2 px-4">
<span className="font-bold">Query has destructive operations</span>
<span className="text-foreground-light">
Make sure you are not accidentally removing something important.
</span>
</li>
)}
{hasUpdateWithoutWhere && (
<li className="grid pt-2 pb-3 px-4 gap-1">
<span className="font-bold">Query uses update without a where clause</span>
<span className="text-foreground-light">
Without a <code className="text-code-inline">where</code> clause, this could
update all rows in the table.
</span>
</li>
)}
{hasAlterDatabasePreventConnection && (
<li className="grid pt-2 pb-3 px-4 gap-1">
<span className="font-bold">Query will prevent connections to your database</span>
<span className="text-foreground-light">
The dashboard will no longer have access to your database, and you will need a
direct connection to your database to reconfigure this setting
</span>
</li>
)}
{hasMissingRLS && (
<li className="grid pt-2 pb-3 px-4 gap-1">
<span className="font-bold">
{missingRLSTables.length === 1
? 'New table will not have Row Level Security enabled'
: 'New tables will not have Row Level Security enabled'}
</span>
<span className="text-foreground-light">
Without RLS, any client using your project's anon or authenticated keys can read
and write to{' '}
{missingRLSTables.length === 1 ? (
<code className="text-code-inline">
{missingRLSTables[0].schema
? `${missingRLSTables[0].schema}.${missingRLSTables[0].tableName}`
: missingRLSTables[0].tableName}
</code>
) : (
'these tables'
)}
. Enable RLS and add policies before exposing this table via the API.{' '}
<a
href={`${DOCS_URL}/guides/database/postgres/row-level-security`}
target="_blank"
rel="noreferrer"
className="underline"
>
Learn more
</a>
.
</span>
</li>
)}
</ul>
</div>
<p className="mt-4 text-sm text-foreground-light">
Please confirm that you would like to execute this query.
</p>
</DialogSection>

<DialogSectionSeparator />

<div className="flex flex-wrap gap-2 px-5 pt-5">
<Button size="medium" type="default" onClick={() => onCancel()}>
Cancel
</Button>
<Button size="medium" type="warning" onClick={onConfirm} className="ml-auto">
{hasMissingRLS ? 'Run without RLS' : 'Run this query'}
</Button>
{hasMissingRLS && onConfirmWithRLS && (
<Button size="medium" type="primary" onClick={onConfirmWithRLS}>
Run and enable RLS
</Button>
)}
</ul>
</div>
<p className="mt-4 text-sm text-foreground-light">
Please confirm that you would like to execute this query.
</p>
</ConfirmationModal>
</div>
</DialogContent>
</Dialog>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,12 @@ export const destructiveSqlRegex = [
/^(.*;)?\s*(drop|delete|truncate|alter\s+table\s+.*\s+drop\s+column)\s/is,
]

// Matches `UPDATE <table> SET ...` where <table> is any combination of bareword
// or double-quoted identifiers, optionally schema-qualified. Quoted identifiers
// can contain any character (including spaces) and use `""` to escape an inner
// quote, mirroring Postgres syntax.
export const updateWithoutWhereRegex =
/(?:^|;)\s*update\s+(?:"[\w.]+"\."[\w.]+"|[\w.]+)\s+set\s+[\w\W]+?(?!\s*where\s)/is
/(?:^|;)\s*update\s+(?:"(?:[^"]|"")+"|[\w]+)(?:\.(?:"(?:[^"]|"")+"|[\w]+))?\s+set\s+[\w\W]+?(?!\s*where\s)/is

export const alterDatabasePreventConnectionStatements = [
'alter database postgres connection limit 0',
Expand Down
44 changes: 41 additions & 3 deletions apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,14 @@ import {
type PotentialIssues,
} from './SQLEditor.types'
import {
appendEnableRLSStatements,
checkAlterDatabaseConnection,
checkDestructiveQuery,
checkIfAppendLimitRequired,
createSqlSnippetSkeletonV2,
filterTablesCoveredByEnsureRLSTrigger,
getCreateTablesMissingRLS,
hasActiveEnsureRLSTrigger,
isUpdateWithoutWhere,
suffixWithLimit,
} from './SQLEditor.utils'
Expand All @@ -56,6 +60,7 @@ import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/L
import ResizableAIWidget from '@/components/ui/AIEditor/ResizableAIWidget'
import { GridFooter } from '@/components/ui/GridFooter'
import { useSqlTitleGenerateMutation } from '@/data/ai/sql-title-mutation'
import { useDatabaseEventTriggersQuery } from '@/data/database-event-triggers/database-event-triggers-query'
import { constructHeaders, isValidConnString } from '@/data/fetchers'
import { lintKeys } from '@/data/lint/keys'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
Expand Down Expand Up @@ -177,6 +182,14 @@ export const SQLEditor = () => {
{ enabled: isValidConnString(project?.connectionString) }
)

const { data: eventTriggers } = useDatabaseEventTriggersQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
},
{ enabled: isValidConnString(project?.connectionString) }
)

/* React query mutations */
const { mutateAsync: generateSqlTitle } = useSqlTitleGenerateMutation()
const { mutate: sendEvent } = useSendEventMutation()
Expand Down Expand Up @@ -298,7 +311,7 @@ export const SQLEditor = () => {
}, [id, isDiffOpen, project, snapV2])

const executeQuery = useCallback(
async (force: boolean = false) => {
async (force: boolean = false, sqlOverride?: string) => {
if (isDiffOpen) {
clearPendingRunRefocus()
return
Expand All @@ -317,23 +330,32 @@ export const SQLEditor = () => {
const selection = editor.getSelection()
const selectedValue = selection ? editor.getModel()?.getValueInRange(selection) : undefined

const sql = snippet
const editorSql = snippet
? ((selectedValue || editorRef.current?.getValue()) ?? snippet.snippet.content?.sql)
: selectedValue || editorRef.current?.getValue()
const sql = sqlOverride ?? editorSql

const hasDestructiveOperations = checkDestructiveQuery(sql)
const hasUpdateWithoutWhere = isUpdateWithoutWhere(sql)
const hasAlterDatabasePreventConnection = checkAlterDatabaseConnection(sql)
const createTablesMissingRLS = filterTablesCoveredByEnsureRLSTrigger(
getCreateTablesMissingRLS(sql),
hasActiveEnsureRLSTrigger(eventTriggers)
)

const queryHasIssues =
!force &&
(hasDestructiveOperations || hasUpdateWithoutWhere || hasAlterDatabasePreventConnection)
(hasDestructiveOperations ||
hasUpdateWithoutWhere ||
hasAlterDatabasePreventConnection ||
createTablesMissingRLS.length > 0)

if (queryHasIssues) {
setPotentialIssues({
hasDestructiveOperations,
hasUpdateWithoutWhere,
hasAlterDatabasePreventConnection,
createTablesMissingRLS,
})
return
}
Expand Down Expand Up @@ -395,6 +417,7 @@ export const SQLEditor = () => {
setAiTitle,
databaseSelectorState.selectedDatabaseId,
databases,
eventTriggers,
limit,
]
)
Expand Down Expand Up @@ -816,6 +839,21 @@ export const SQLEditor = () => {
refocusEditor()
void executeQuery(true)
}}
onConfirmWithRLS={() => {
const tables = potentialIssues?.createTablesMissingRLS ?? []
if (tables.length === 0) return
const editor = editorRef.current
const selection = editor?.getSelection()
const selectedValue = selection
? editor?.getModel()?.getValueInRange(selection)
: undefined
const baseSql = selectedValue || editor?.getValue() || ''
const rewrittenSql = appendEnableRLSStatements(baseSql, tables)
shouldRefocusAfterRunRef.current = true
setPotentialIssues(undefined)
refocusEditor()
void executeQuery(true, rewrittenSql)
}}
/>

<div className="flex h-full">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ export type PotentialIssues = {
hasDestructiveOperations?: boolean
hasUpdateWithoutWhere?: boolean
hasAlterDatabasePreventConnection?: boolean
createTablesMissingRLS?: { schema?: string; tableName: string }[]
}
Loading
Loading