Files
supabase/apps/studio/lib/sql-event-parser.ts
T
Alaister YoungandAlaister Young 3aed9a9a2a feat(studio): warn before CREATE TABLE without RLS in SQL editor (#45008)
Adds a pre-execution warning in the SQL editor when a `CREATE TABLE`
statement is run without enabling Row Level Security on the new table.
Responds to the press call-out around SQL editor security.

<img width="708" height="498" alt="Screenshot 2026-04-18 at 4 31 07 PM"
src="https://github.com/user-attachments/assets/4f23ed5e-f32c-46f0-b0da-ac6d4c661c7c"
/>


**Added:**
- Pre-execution check in `executeQuery` that detects `CREATE TABLE`
statements without a matching `ALTER TABLE ... ENABLE ROW LEVEL
SECURITY` in the same submitted SQL.
- New "Run and enable RLS" action in the warning modal that rewrites the
SQL to append `ALTER TABLE [schema.]<table> ENABLE ROW LEVEL SECURITY;`
for each detected table before running.
- Link in the modal to the RLS docs.

**Changed:**
- `RunQueryWarningModal` now renders `Dialog` directly (instead of
`ConfirmationModal`) so it can show three buttons: Cancel / Run without
RLS / Run and enable RLS.
- `sqlEventParser` table-name regex now supports quoted identifiers
containing spaces (e.g. `"My Table"`) and escaped quotes (e.g.
`"user""table"`).

The check runs against the SQL that's actually submitted, so
partial-selection works correctly — selecting only the `CREATE TABLE`
portion will trigger the warning even if there's a matching `ENABLE RLS`
lower in the editor.

## To test

- Open the SQL editor and run `create table foo (id int8 primary key);`
→ modal should appear with the RLS warning bullet and three buttons.
- Click **Run and enable RLS** → query runs, table is created with RLS
enabled.
- Click **Run without RLS** → query runs as written, no RLS.
- Run `create table foo (id int8); alter table foo enable row level
security;` → no modal (RLS already enabled in same submission).
- Run `create table public.bar (id int8); create table baz (id int8);
alter table baz enable rls;` → modal flags only `public.bar`.
- Select only the `create table` portion of a snippet that also enables
RLS lower down and run the selection → modal should still fire.
- Run an existing destructive query (`drop table x`) → modal still works
as before with two buttons (Cancel / Run this query).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* SQL editor now detects CREATE TABLE statements missing Row Level
Security (RLS) and shows counts and dynamic table/schema details in a
redesigned warning dialog with updated pluralization and a “Learn more”
link.
* New actions: “Run without RLS” and, when available, “Run and enable
RLS” which applies RLS and runs the query; editor can execute an
overridden SQL payload when applying RLS changes.

* **Tests**
* Added comprehensive unit and e2e tests covering RLS detection, SQL
augmentation, trigger handling, identifier parsing, and the “Run and
enable RLS” flow.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-04-18 17:18:12 +08:00

129 lines
4.1 KiB
TypeScript

/**
* Lightweight SQL parser for telemetry event detection.
*
* [Sean] Replace this with a proper SQL parser like `@supabase/pg-parser` once a
* browser-compatible version is available.
*/
import { TABLE_EVENT_ACTIONS, TableEventAction } from 'common/telemetry-constants'
export interface TableEventDetails {
type: TableEventAction
schema?: string
tableName?: string
}
type Detector = {
type: TableEventAction
patterns: RegExp[]
}
export class SQLEventParser {
private static DETECTORS: Detector[] = [
{
type: TABLE_EVENT_ACTIONS.TableCreated,
patterns: [
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i,
/CREATE\s+TEMP(?:ORARY)?\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i,
/CREATE\s+UNLOGGED\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i,
/SELECT\s+.*?\s+INTO\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/is,
/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))\s+AS\s+SELECT/i,
],
},
{
type: TABLE_EVENT_ACTIONS.TableDataAdded,
patterns: [
/INSERT\s+INTO\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))/i,
/COPY\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+))\s+FROM/i,
],
},
{
type: TABLE_EVENT_ACTIONS.TableRLSEnabled,
patterns: [
/ALTER\s+TABLE\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+)).*?ENABLE\s+ROW\s+LEVEL\s+SECURITY/i,
/ALTER\s+TABLE\s+(?<schema>(?:"[^"]+"|[\w]+)\.)?(?<table>(?:"(?:[^"]|"")+"|`(?:[^`]|``)+`|[\w]+)).*?ENABLE\s+RLS/i,
],
},
]
private cleanIdentifier(identifier?: string) {
return identifier?.replace(/["`']/g, '').replace(/\.$/, '')
}
private match(sql: string): TableEventDetails | null {
for (const { type, patterns } of SQLEventParser.DETECTORS) {
for (const pattern of patterns) {
const match = sql.match(pattern)
if (match?.groups) {
return {
type,
schema: this.cleanIdentifier(match.groups.schema),
tableName: this.cleanIdentifier(match.groups.table ?? match.groups.object),
}
}
}
}
return null
}
private splitStatements(sql: string): string[] {
// Regex matches:
// - single quotes ('...') with escapes
// - double quotes ("...")
// - dollar-quoted blocks ($$...$$ or $tag$...$tag$)
// - semicolons
// - everything else
const tokens =
sql.match(
/'([^']|'')*'|"([^"]|"")*"|\$[a-zA-Z0-9_]*\$[\s\S]*?\$[a-zA-Z0-9_]*\$|;|[^'"$;]+/g
) || []
const statements: string[] = []
let current = ''
for (const token of tokens) {
if (token === ';') {
if (current.trim()) statements.push(current.trim())
current = ''
} else {
current += token
}
}
if (current.trim()) {
statements.push(current.trim())
}
return statements
}
private deduplicate(events: TableEventDetails[]): TableEventDetails[] {
const seen = new Set<string>()
return events.filter((e) => {
const key = `${e.type}:${e.schema || ''}:${e.tableName || ''}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
private removeComments(sql: string): string {
return sql
.replace(/--.*?$/gm, '') // line comments
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
}
getTableEvents(sql: string): TableEventDetails[] {
const statements = this.splitStatements(this.removeComments(sql))
const results: TableEventDetails[] = []
for (const stmt of statements) {
const event = this.match(stmt)
if (event) results.push(event)
}
return this.deduplicate(results)
}
}
export const sqlEventParser = new SQLEventParser()