|
|
@@ -1,7 +1,6 @@
|
|
|
import 'server-only'
|
|
|
import { db } from '@/lib/db'
|
|
|
import type OpenAI from 'openai'
|
|
|
-import { Prisma } from '@/generated/prisma/client'
|
|
|
|
|
|
const MAX_ROWS = 100
|
|
|
|
|
|
@@ -20,7 +19,8 @@ export const workshopTools: OpenAI.ChatCompletionTool[] = [
|
|
|
properties: {
|
|
|
sql: {
|
|
|
type: 'string',
|
|
|
- description: 'A PostgreSQL SELECT query. Must start with SELECT.',
|
|
|
+ description:
|
|
|
+ 'A PostgreSQL SELECT query. Must start with SELECT or WITH. Join tables with explicit JOIN ... ON; comma-separated tables in FROM are rejected.',
|
|
|
},
|
|
|
},
|
|
|
required: ['sql'],
|
|
|
@@ -29,9 +29,9 @@ export const workshopTools: OpenAI.ChatCompletionTool[] = [
|
|
|
},
|
|
|
]
|
|
|
|
|
|
-// ─── Blocked keywords (mutation / DDL / admin) ─────────────────────────────
|
|
|
+// ─── Blocked keywords (mutation / DDL / admin / locking) ───────────────────
|
|
|
|
|
|
-const BLOCKED_KEYWORDS = [
|
|
|
+const BLOCKED_KEYWORDS = new Set([
|
|
|
'INSERT',
|
|
|
'UPDATE',
|
|
|
'DELETE',
|
|
|
@@ -65,9 +65,33 @@ const BLOCKED_KEYWORDS = [
|
|
|
'SECURITY',
|
|
|
'REASSIGN',
|
|
|
'REFRESH',
|
|
|
-] as const
|
|
|
-
|
|
|
-// Tables the AI is allowed to query (whitelist approach — everything else is blocked)
|
|
|
+ // Read a table without FROM/JOIN, pull functions in as row sources, or
|
|
|
+ // sample rows: none of these go through the table walker below.
|
|
|
+ 'TABLE',
|
|
|
+ 'LATERAL',
|
|
|
+ 'TABLESAMPLE',
|
|
|
+ 'IMPORT',
|
|
|
+ 'DO',
|
|
|
+ 'EXPLAIN',
|
|
|
+ 'SHOW',
|
|
|
+])
|
|
|
+
|
|
|
+// Functions that read files, other databases or session settings.
|
|
|
+const UNSAFE_FUNCTIONS = new Set([
|
|
|
+ 'lo_import',
|
|
|
+ 'lo_export',
|
|
|
+ 'dblink',
|
|
|
+ 'dblink_connect',
|
|
|
+ 'query_to_xml',
|
|
|
+ 'current_setting',
|
|
|
+ 'set_config',
|
|
|
+])
|
|
|
+
|
|
|
+// `FOR` followed by one of these is a row lock (FOR UPDATE, FOR NO KEY UPDATE,
|
|
|
+// FOR SHARE, FOR KEY SHARE).
|
|
|
+const LOCK_STRENGTH = new Set(['UPDATE', 'NO', 'SHARE', 'KEY'])
|
|
|
+
|
|
|
+// Tables the AI is allowed to query (whitelist approach: everything else is blocked)
|
|
|
const ALLOWED_TABLES = [
|
|
|
'vehicles',
|
|
|
'service_records',
|
|
|
@@ -95,180 +119,454 @@ const ALLOWED_TABLES = [
|
|
|
'inspection_quote_requests',
|
|
|
] as const
|
|
|
|
|
|
-// ─── Validation ─────────────────────────────────────────────────────────────
|
|
|
-
|
|
|
-export function validateSql(sql: string): { valid: boolean; error?: string } {
|
|
|
- const trimmed = sql.trim()
|
|
|
+const ALLOWED_TABLE_SET: ReadonlySet<string> = new Set(ALLOWED_TABLES)
|
|
|
+
|
|
|
+// ─── Tokenizer ──────────────────────────────────────────────────────────────
|
|
|
+//
|
|
|
+// The table check below runs on tokens, not on the raw text, so a string
|
|
|
+// literal that says "from users" is data and a quoted identifier is a name.
|
|
|
+// Anything the tokenizer cannot place with certainty (comments, dollar
|
|
|
+// quoting, a second statement) is rejected rather than guessed at.
|
|
|
+
|
|
|
+type Token =
|
|
|
+ | { kind: 'ident'; value: string; quoted: boolean }
|
|
|
+ | { kind: 'string' }
|
|
|
+ | { kind: 'number' }
|
|
|
+ | { kind: 'punct'; value: string }
|
|
|
+
|
|
|
+const IDENT_START = /[A-Za-z_\u0080-\uFFFF]/
|
|
|
+const IDENT_PART = /[A-Za-z0-9_\u0080-\uFFFF]/
|
|
|
+
|
|
|
+/** Index just past the closing quote, or -1 when the literal never ends. */
|
|
|
+function readString(sql: string, from: number, backslashEscapes: boolean): number {
|
|
|
+ let i = from
|
|
|
+ while (i < sql.length) {
|
|
|
+ const ch = sql[i]
|
|
|
+ if (backslashEscapes && ch === '\\') {
|
|
|
+ i += 2
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (ch === "'") {
|
|
|
+ if (sql[i + 1] === "'") {
|
|
|
+ i += 2
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ return i + 1
|
|
|
+ }
|
|
|
+ i++
|
|
|
+ }
|
|
|
+ return -1
|
|
|
+}
|
|
|
|
|
|
- // Must start with SELECT (case-insensitive)
|
|
|
- if (!/^SELECT\b/i.test(trimmed)) {
|
|
|
- return { valid: false, error: 'Only SELECT queries are allowed.' }
|
|
|
+function readQuotedIdent(sql: string, from: number): { value: string; end: number } | null {
|
|
|
+ let i = from
|
|
|
+ let value = ''
|
|
|
+ while (i < sql.length) {
|
|
|
+ const ch = sql[i]
|
|
|
+ if (ch === '"') {
|
|
|
+ if (sql[i + 1] === '"') {
|
|
|
+ value += '"'
|
|
|
+ i += 2
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ return { value, end: i + 1 }
|
|
|
+ }
|
|
|
+ value += ch
|
|
|
+ i++
|
|
|
}
|
|
|
+ return null
|
|
|
+}
|
|
|
|
|
|
- // Must not contain semicolons (prevent multi-statement injection)
|
|
|
- if (trimmed.includes(';')) {
|
|
|
- return { valid: false, error: 'Multiple statements are not allowed.' }
|
|
|
+function tokenize(sql: string): { tokens: Token[]; error?: string } {
|
|
|
+ const tokens: Token[] = []
|
|
|
+ const fail = (error: string) => ({ tokens, error })
|
|
|
+ let i = 0
|
|
|
+ while (i < sql.length) {
|
|
|
+ const ch = sql[i]
|
|
|
+ if (/\s/.test(ch)) {
|
|
|
+ i++
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (ch === '-' && sql[i + 1] === '-') return fail('SQL comments are not allowed.')
|
|
|
+ if (ch === '/' && sql[i + 1] === '*') return fail('SQL block comments are not allowed.')
|
|
|
+ if (ch === ';') return fail('Multiple statements are not allowed.')
|
|
|
+ if (ch === '$') return fail('Dollar quoting and parameters are not allowed.')
|
|
|
+ if (ch === "'") {
|
|
|
+ const end = readString(sql, i + 1, false)
|
|
|
+ if (end < 0) return fail('Unterminated string literal.')
|
|
|
+ tokens.push({ kind: 'string' })
|
|
|
+ i = end
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (ch === '"') {
|
|
|
+ const ident = readQuotedIdent(sql, i + 1)
|
|
|
+ if (!ident) return fail('Unterminated quoted identifier.')
|
|
|
+ tokens.push({ kind: 'ident', value: ident.value, quoted: true })
|
|
|
+ i = ident.end
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (IDENT_START.test(ch)) {
|
|
|
+ let j = i + 1
|
|
|
+ while (j < sql.length && IDENT_PART.test(sql[j])) j++
|
|
|
+ const word = sql.slice(i, j)
|
|
|
+ // E'...' honours backslash escapes, so the literal has to end where
|
|
|
+ // Postgres ends it. Plain, U&, B, X and N strings escape a quote only
|
|
|
+ // by doubling it (standard_conforming_strings).
|
|
|
+ if ((word === 'E' || word === 'e') && sql[j] === "'") {
|
|
|
+ const end = readString(sql, j + 1, true)
|
|
|
+ if (end < 0) return fail('Unterminated string literal.')
|
|
|
+ tokens.push({ kind: 'string' })
|
|
|
+ i = end
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ tokens.push({ kind: 'ident', value: word, quoted: false })
|
|
|
+ i = j
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (/[0-9]/.test(ch)) {
|
|
|
+ let j = i + 1
|
|
|
+ while (j < sql.length && /[0-9.]/.test(sql[j])) j++
|
|
|
+ tokens.push({ kind: 'number' })
|
|
|
+ i = j
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ tokens.push({ kind: 'punct', value: ch })
|
|
|
+ i++
|
|
|
}
|
|
|
+ return { tokens }
|
|
|
+}
|
|
|
+
|
|
|
+// ─── Table reference walker ─────────────────────────────────────────────────
|
|
|
+
|
|
|
+const UNBALANCED = 'Unbalanced parentheses.'
|
|
|
+
|
|
|
+// After one of these the FROM list is over, so a comma is no longer a table
|
|
|
+// separator.
|
|
|
+const CLAUSE_KEYWORDS = new Set([
|
|
|
+ 'WHERE',
|
|
|
+ 'GROUP',
|
|
|
+ 'HAVING',
|
|
|
+ 'WINDOW',
|
|
|
+ 'ORDER',
|
|
|
+ 'LIMIT',
|
|
|
+ 'OFFSET',
|
|
|
+ 'FETCH',
|
|
|
+ 'FOR',
|
|
|
+ 'UNION',
|
|
|
+ 'INTERSECT',
|
|
|
+ 'EXCEPT',
|
|
|
+ 'RETURNING',
|
|
|
+])
|
|
|
+
|
|
|
+const SUBQUERY_KEYWORDS = new Set(['SELECT', 'WITH', 'VALUES'])
|
|
|
+
|
|
|
+/** Upper-cased bare identifier, or null for anything that cannot be a keyword. */
|
|
|
+function keywordOf(tok: Token | undefined): string | null {
|
|
|
+ return tok && tok.kind === 'ident' && !tok.quoted ? tok.value.toUpperCase() : null
|
|
|
+}
|
|
|
|
|
|
- // Block SQL comments
|
|
|
- if (/--/.test(trimmed)) {
|
|
|
- return { valid: false, error: 'SQL comments are not allowed.' }
|
|
|
+function isKeyword(tok: Token | undefined, keyword: string): boolean {
|
|
|
+ return keywordOf(tok) === keyword
|
|
|
+}
|
|
|
+
|
|
|
+function isPunct(tok: Token | undefined, value: string): tok is Extract<Token, { kind: 'punct' }> {
|
|
|
+ return tok?.kind === 'punct' && tok.value === value
|
|
|
+}
|
|
|
+
|
|
|
+/** The name Postgres resolves: quoted as written, bare folded to lower case. */
|
|
|
+function identName(tok: Extract<Token, { kind: 'ident' }>): string {
|
|
|
+ return tok.quoted ? tok.value : tok.value.toLowerCase()
|
|
|
+}
|
|
|
+
|
|
|
+/** Index of the bracket closing the one at `open`, or -1. */
|
|
|
+function matchingClose(tokens: Token[], open: number, to: number, close: string): number {
|
|
|
+ const openCh = (tokens[open] as { value: string }).value
|
|
|
+ let depth = 0
|
|
|
+ for (let i = open; i < to; i++) {
|
|
|
+ const tok = tokens[i]
|
|
|
+ if (tok.kind !== 'punct') continue
|
|
|
+ if (tok.value === openCh) depth++
|
|
|
+ else if (tok.value === close && --depth === 0) return i
|
|
|
}
|
|
|
- if (/\/\*/.test(trimmed)) {
|
|
|
- return { valid: false, error: 'SQL block comments are not allowed.' }
|
|
|
+ return -1
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Walks a statement or subquery. `ctes` are the names visible from enclosing
|
|
|
+ * WITH clauses; a leading WITH here extends them for the rest of the fragment.
|
|
|
+ */
|
|
|
+function checkFragment(
|
|
|
+ tokens: Token[],
|
|
|
+ from: number,
|
|
|
+ to: number,
|
|
|
+ ctes: ReadonlySet<string>
|
|
|
+): string | null {
|
|
|
+ let i = from
|
|
|
+ let visible = ctes
|
|
|
+ if (isKeyword(tokens[i], 'WITH')) {
|
|
|
+ const local = new Set(ctes)
|
|
|
+ i++
|
|
|
+ if (isKeyword(tokens[i], 'RECURSIVE')) return 'Recursive queries are not allowed.'
|
|
|
+ for (;;) {
|
|
|
+ const name = tokens[i]
|
|
|
+ if (!name || name.kind !== 'ident') return 'Malformed WITH clause.'
|
|
|
+ i++
|
|
|
+ if (isPunct(tokens[i], '(')) {
|
|
|
+ const close = matchingClose(tokens, i, to, ')')
|
|
|
+ if (close < 0) return UNBALANCED
|
|
|
+ i = close + 1
|
|
|
+ }
|
|
|
+ if (!isKeyword(tokens[i], 'AS')) return 'Malformed WITH clause.'
|
|
|
+ i++
|
|
|
+ if (isKeyword(tokens[i], 'NOT')) i++
|
|
|
+ if (isKeyword(tokens[i], 'MATERIALIZED')) i++
|
|
|
+ if (!isPunct(tokens[i], '(')) return 'Malformed WITH clause.'
|
|
|
+ const close = matchingClose(tokens, i, to, ')')
|
|
|
+ if (close < 0) return UNBALANCED
|
|
|
+ // The name becomes visible only after its own body: without RECURSIVE,
|
|
|
+ // `WITH users AS (SELECT * FROM users)` reads the real users table.
|
|
|
+ const error = checkFragment(tokens, i + 1, close, local)
|
|
|
+ if (error) return error
|
|
|
+ local.add(identName(name))
|
|
|
+ i = close + 1
|
|
|
+ if (isPunct(tokens[i], ',')) {
|
|
|
+ i++
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ break
|
|
|
+ }
|
|
|
+ visible = local
|
|
|
}
|
|
|
+ return checkBody(tokens, i, to, visible, false)
|
|
|
+}
|
|
|
|
|
|
- // Block ANY schema-qualified table references (schema.table or "schema"."table")
|
|
|
- // This catches public.vehicles, pg_temp.vehicles, myschema.anything, etc.
|
|
|
- if (/\b[a-zA-Z_][a-zA-Z0-9_]*\s*\.\s*"?[a-zA-Z_]/i.test(trimmed)) {
|
|
|
- // Allow column references like t.id or vehicles.id (single letter or allowed table aliases)
|
|
|
- // but block schema references by checking if left side looks like a schema name
|
|
|
- const schemaPattern = /\b(public|pg_temp|pg_catalog|information_schema)\s*\./i
|
|
|
- if (schemaPattern.test(trimmed)) {
|
|
|
- return { valid: false, error: 'Schema-qualified table names are not allowed.' }
|
|
|
+/**
|
|
|
+ * Walks the tokens at one bracket depth. FROM introduces a table only once a
|
|
|
+ * SELECT was seen at this depth, which leaves EXTRACT(YEAR FROM x) and friends
|
|
|
+ * alone inside their own parentheses; JOIN always introduces one. Every
|
|
|
+ * bracket group is walked recursively, so a subquery anywhere is found.
|
|
|
+ */
|
|
|
+function checkBody(
|
|
|
+ tokens: Token[],
|
|
|
+ from: number,
|
|
|
+ to: number,
|
|
|
+ ctes: ReadonlySet<string>,
|
|
|
+ startsWithTable: boolean
|
|
|
+): string | null {
|
|
|
+ let i = from
|
|
|
+ let selectSeen = false
|
|
|
+ let inFromList = false
|
|
|
+ if (startsWithTable) {
|
|
|
+ const next = checkTableItem(tokens, i, to, ctes)
|
|
|
+ if (typeof next === 'string') return next
|
|
|
+ i = next
|
|
|
+ inFromList = true
|
|
|
+ }
|
|
|
+ while (i < to) {
|
|
|
+ const tok = tokens[i]
|
|
|
+ if (isPunct(tok, '(') || isPunct(tok, '[')) {
|
|
|
+ const close = matchingClose(tokens, i, to, tok.value === '(' ? ')' : ']')
|
|
|
+ if (close < 0) return UNBALANCED
|
|
|
+ const error = checkFragment(tokens, i + 1, close, ctes)
|
|
|
+ if (error) return error
|
|
|
+ i = close + 1
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if (isPunct(tok, ')') || isPunct(tok, ']')) return UNBALANCED
|
|
|
+ if (inFromList && isPunct(tok, ',')) {
|
|
|
+ return 'Comma-separated tables in FROM are not allowed. Use an explicit JOIN.'
|
|
|
}
|
|
|
- // Also block "quoted"."qualified" patterns
|
|
|
- if (/"[^"]+"\s*\.\s*"[^"]+"/.test(trimmed)) {
|
|
|
- return { valid: false, error: 'Schema-qualified table names are not allowed.' }
|
|
|
+ const keyword = keywordOf(tok)
|
|
|
+ if (keyword === 'SELECT') {
|
|
|
+ selectSeen = true
|
|
|
+ inFromList = false
|
|
|
+ i++
|
|
|
+ continue
|
|
|
}
|
|
|
+ if (keyword && CLAUSE_KEYWORDS.has(keyword)) {
|
|
|
+ inFromList = false
|
|
|
+ i++
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ if ((keyword === 'FROM' && selectSeen) || keyword === 'JOIN') {
|
|
|
+ const next = checkTableItem(tokens, i + 1, to, ctes)
|
|
|
+ if (typeof next === 'string') return next
|
|
|
+ i = next
|
|
|
+ inFromList = true
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ i++
|
|
|
}
|
|
|
+ return null
|
|
|
+}
|
|
|
|
|
|
- // Block information_schema / pg_catalog / pg_temp access
|
|
|
- if (
|
|
|
- /\b(information_schema|pg_catalog|pg_temp|pg_tables|pg_views|pg_roles|pg_user)\b/i.test(trimmed)
|
|
|
- ) {
|
|
|
- return { valid: false, error: 'System catalog access is not allowed.' }
|
|
|
+/**
|
|
|
+ * Checks the table item after FROM or JOIN: an allowed table, a CTE name, a
|
|
|
+ * parenthesised subquery, or a parenthesised join. Returns the index after
|
|
|
+ * the item, or an error.
|
|
|
+ */
|
|
|
+function checkTableItem(
|
|
|
+ tokens: Token[],
|
|
|
+ i: number,
|
|
|
+ to: number,
|
|
|
+ ctes: ReadonlySet<string>
|
|
|
+): number | string {
|
|
|
+ const tok = i < to ? tokens[i] : undefined
|
|
|
+ if (!tok) return 'Missing table name after FROM or JOIN.'
|
|
|
+ if (isPunct(tok, '(')) {
|
|
|
+ const close = matchingClose(tokens, i, to, ')')
|
|
|
+ if (close < 0) return UNBALANCED
|
|
|
+ const first = keywordOf(tokens[i + 1])
|
|
|
+ const error =
|
|
|
+ first && SUBQUERY_KEYWORDS.has(first)
|
|
|
+ ? checkFragment(tokens, i + 1, close, ctes)
|
|
|
+ : checkBody(tokens, i + 1, close, ctes, true)
|
|
|
+ return error ?? close + 1
|
|
|
}
|
|
|
+ if (tok.kind !== 'ident') return 'Unexpected token after FROM or JOIN.'
|
|
|
+ if (isPunct(tokens[i + 1], '.')) return 'Schema-qualified table names are not allowed.'
|
|
|
+ const name = identName(tok)
|
|
|
+ if (ctes.has(name) || ALLOWED_TABLE_SET.has(name)) return i + 1
|
|
|
+ return `Access to table "${name}" is not allowed.`
|
|
|
+}
|
|
|
|
|
|
- // Block Postgres function calls that could leak data or cause side effects
|
|
|
- if (
|
|
|
- /\b(pg_read_file|pg_ls_dir|lo_import|lo_export|dblink|query_to_xml|current_setting|set_config)\b/i.test(
|
|
|
- trimmed
|
|
|
- )
|
|
|
- ) {
|
|
|
- return { valid: false, error: 'Unsafe function call is not allowed.' }
|
|
|
- }
|
|
|
+// ─── Validation ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
- // Normalize for keyword checking: collapse whitespace, uppercase
|
|
|
- const normalized = trimmed.replace(/\s+/g, ' ').toUpperCase()
|
|
|
+export function validateSql(sql: string): { valid: boolean; error?: string } {
|
|
|
+ const trimmed = sql.trim()
|
|
|
|
|
|
- // Check for blocked mutation/DDL keywords
|
|
|
- for (const keyword of BLOCKED_KEYWORDS) {
|
|
|
- const regex = new RegExp(`\\b${keyword}\\b`)
|
|
|
- if (regex.test(normalized)) {
|
|
|
- return { valid: false, error: `Forbidden keyword: ${keyword}` }
|
|
|
- }
|
|
|
+ if (!/^(SELECT|WITH)\b/i.test(trimmed)) {
|
|
|
+ return { valid: false, error: 'Only SELECT queries are allowed.' }
|
|
|
}
|
|
|
|
|
|
- // Whitelist: extract all table-like identifiers from FROM / JOIN clauses
|
|
|
- // and verify they're in the allowed list
|
|
|
- const tableRefPattern = /\b(?:FROM|JOIN)\s+"?([a-z_][a-z0-9_]*)"?/gi
|
|
|
- let match
|
|
|
- while ((match = tableRefPattern.exec(trimmed)) !== null) {
|
|
|
- const tableName = match[1].toLowerCase()
|
|
|
- if (!ALLOWED_TABLES.includes(tableName as (typeof ALLOWED_TABLES)[number])) {
|
|
|
- return { valid: false, error: `Access to table "${tableName}" is not allowed.` }
|
|
|
+ const lexed = tokenize(trimmed)
|
|
|
+ if (lexed.error) return { valid: false, error: lexed.error }
|
|
|
+ const { tokens } = lexed
|
|
|
+
|
|
|
+ for (let i = 0; i < tokens.length; i++) {
|
|
|
+ const tok = tokens[i]
|
|
|
+ if (tok.kind !== 'ident') continue
|
|
|
+ const lower = tok.value.toLowerCase()
|
|
|
+ // Catalog names are refused wherever they appear, quoted or not.
|
|
|
+ if (lower.startsWith('pg_') || lower === 'information_schema') {
|
|
|
+ return { valid: false, error: 'System catalog access is not allowed.' }
|
|
|
+ }
|
|
|
+ if (tok.quoted) continue
|
|
|
+ const upper = tok.value.toUpperCase()
|
|
|
+ if (BLOCKED_KEYWORDS.has(upper)) {
|
|
|
+ return { valid: false, error: `Forbidden keyword: ${upper}` }
|
|
|
+ }
|
|
|
+ if (UNSAFE_FUNCTIONS.has(lower)) {
|
|
|
+ return { valid: false, error: 'Unsafe function call is not allowed.' }
|
|
|
+ }
|
|
|
+ if (upper === 'FOR' && LOCK_STRENGTH.has(keywordOf(tokens[i + 1]) ?? '')) {
|
|
|
+ return { valid: false, error: 'Row locking is not allowed.' }
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ const error = checkFragment(tokens, 0, tokens.length, new Set())
|
|
|
+ if (error) return { valid: false, error }
|
|
|
+
|
|
|
return { valid: true }
|
|
|
}
|
|
|
|
|
|
// ─── Org-scoped execution ───────────────────────────────────────────────────
|
|
|
|
|
|
+// Tables that carry their own organizationId. Service records are here rather
|
|
|
+// than under vehicles because counter sales have no vehicle.
|
|
|
+const ORG_DIRECT_TABLES = [
|
|
|
+ 'vehicles',
|
|
|
+ 'customers',
|
|
|
+ 'quotes',
|
|
|
+ 'inventory_parts',
|
|
|
+ 'notifications',
|
|
|
+ 'sms_messages',
|
|
|
+ 'technicians',
|
|
|
+ 'inspections',
|
|
|
+ 'inspection_quote_requests',
|
|
|
+ 'service_records',
|
|
|
+]
|
|
|
+
|
|
|
+// Tables without an organizationId, scoped by joining their already-scoped
|
|
|
+// parent view. Order matters: recurring_invoices must exist before its children.
|
|
|
+const CHILD_TABLES: { tables: string[]; parent: string; key: string }[] = [
|
|
|
+ {
|
|
|
+ tables: ['notes', 'fuel_logs', 'reminders', 'recurring_invoices'],
|
|
|
+ parent: 'vehicles',
|
|
|
+ key: 'vehicleId',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ tables: ['service_parts', 'service_labor', 'service_attachments', 'payments'],
|
|
|
+ parent: 'service_records',
|
|
|
+ key: 'serviceRecordId',
|
|
|
+ },
|
|
|
+ { tables: ['quote_parts', 'quote_labor', 'quote_attachments'], parent: 'quotes', key: 'quoteId' },
|
|
|
+ {
|
|
|
+ tables: ['recurring_parts', 'recurring_labor'],
|
|
|
+ parent: 'recurring_invoices',
|
|
|
+ key: 'recurringInvoiceId',
|
|
|
+ },
|
|
|
+ { tables: ['inspection_items'], parent: 'inspections', key: 'inspectionId' },
|
|
|
+]
|
|
|
+
|
|
|
/**
|
|
|
* Executes the AI query inside a READ ONLY transaction with temporary views
|
|
|
* that enforce organization isolation at the database level.
|
|
|
*
|
|
|
* Security layers:
|
|
|
- * 1. Transaction is SET TRANSACTION READ ONLY — cannot mutate anything
|
|
|
+ * 1. validateSql() has already limited the statement to allowed table names
|
|
|
* 2. Temporary views shadow every allowed table name, pre-filtered by orgId
|
|
|
- * 3. Child tables (service_records, payments, etc.) are scoped via JOIN
|
|
|
- * to their org-scoped parent — no direct organizationId needed
|
|
|
- * 4. The orgId is passed as a parameterized value, not string-interpolated
|
|
|
- * 5. search_path is set to pg_temp first, so unqualified table names
|
|
|
- * always resolve to our temporary views, never the real tables
|
|
|
+ * 3. Child tables (service_parts, payments, etc.) are scoped via JOIN to
|
|
|
+ * their org-scoped parent view, so they need no organizationId of their own
|
|
|
+ * 4. The orgId is regex-validated (CUID characters only) and then string
|
|
|
+ * interpolated into the view definitions: CREATE VIEW cannot take a bound
|
|
|
+ * parameter, so the regex is what keeps it safe
|
|
|
+ * 5. search_path puts pg_temp first, so unqualified names in the AI query
|
|
|
+ * resolve to the views, never the real tables
|
|
|
+ * 6. The transaction is SET TRANSACTION READ ONLY before the AI query runs
|
|
|
+ * 7. DISCARD TEMP drops the views before the connection goes back to the
|
|
|
+ * pool, and the rest of the app qualifies its raw queries as
|
|
|
+ * "public"."table" so a stray view could never be picked up by mistake
|
|
|
*/
|
|
|
async function executeOrgScopedQuery(sql: string, orgId: string): Promise<unknown[]> {
|
|
|
- // Validate orgId is a safe CUID (alphanumeric only) — defense-in-depth
|
|
|
+ // Defense-in-depth: the orgId is interpolated below, so it must be a plain CUID
|
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(orgId)) {
|
|
|
throw new Error('Invalid organization ID')
|
|
|
}
|
|
|
|
|
|
return db.$transaction(async (tx) => {
|
|
|
- // 1. Set search_path so pg_temp (temp views) takes priority over public
|
|
|
await tx.$executeRawUnsafe('SET LOCAL search_path TO pg_temp, public')
|
|
|
|
|
|
- // 2. Create temp views BEFORE setting read-only (CREATE VIEW requires write)
|
|
|
- // These views are the security boundary — they enforce org isolation.
|
|
|
- const orgDirectTables = [
|
|
|
- 'vehicles',
|
|
|
- 'customers',
|
|
|
- 'quotes',
|
|
|
- 'inventory_parts',
|
|
|
- 'notifications',
|
|
|
- 'sms_messages',
|
|
|
- 'technicians',
|
|
|
- 'inspections',
|
|
|
- 'inspection_quote_requests',
|
|
|
- // Service records carry their own organizationId (counter sales have no
|
|
|
- // vehicle, so scoping through vehicles would hide them).
|
|
|
- 'service_records',
|
|
|
- ]
|
|
|
-
|
|
|
- for (const table of orgDirectTables) {
|
|
|
- await tx.$executeRaw`${Prisma.raw(
|
|
|
+ // The views are created before the transaction goes read-only, since
|
|
|
+ // CREATE VIEW needs write access. They are the security boundary.
|
|
|
+ for (const table of ORG_DIRECT_TABLES) {
|
|
|
+ await tx.$executeRawUnsafe(
|
|
|
`CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT * FROM "public"."${table}" WHERE "organizationId" = '${orgId}'`
|
|
|
- )}`
|
|
|
- }
|
|
|
-
|
|
|
- // 4. Create temp views for child tables scoped through their parent
|
|
|
- // These tables don't have organizationId directly, so we JOIN to
|
|
|
- // the already-scoped parent view.
|
|
|
-
|
|
|
- // Children of vehicles (via vehicleId → vehicles.id)
|
|
|
- const vehicleChildren = ['notes', 'fuel_logs', 'reminders', 'recurring_invoices']
|
|
|
- for (const table of vehicleChildren) {
|
|
|
- await tx.$executeRaw`${Prisma.raw(
|
|
|
- `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN "vehicles" v ON t."vehicleId" = v.id`
|
|
|
- )}`
|
|
|
- }
|
|
|
-
|
|
|
- // Children of service_records (via serviceRecordId → service_records.id)
|
|
|
- const serviceChildren = ['service_parts', 'service_labor', 'service_attachments', 'payments']
|
|
|
- for (const table of serviceChildren) {
|
|
|
- await tx.$executeRaw`${Prisma.raw(
|
|
|
- `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN "service_records" sr ON t."serviceRecordId" = sr.id`
|
|
|
- )}`
|
|
|
+ )
|
|
|
}
|
|
|
-
|
|
|
- // Children of quotes (via quoteId → quotes.id)
|
|
|
- const quoteChildren = ['quote_parts', 'quote_labor', 'quote_attachments']
|
|
|
- for (const table of quoteChildren) {
|
|
|
- await tx.$executeRaw`${Prisma.raw(
|
|
|
- `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN "quotes" q ON t."quoteId" = q.id`
|
|
|
- )}`
|
|
|
+ for (const { tables, parent, key } of CHILD_TABLES) {
|
|
|
+ for (const table of tables) {
|
|
|
+ await tx.$executeRawUnsafe(
|
|
|
+ `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN pg_temp."${parent}" p ON t."${key}" = p.id`
|
|
|
+ )
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- // Children of recurring_invoices
|
|
|
- const recurringChildren = ['recurring_parts', 'recurring_labor']
|
|
|
- for (const table of recurringChildren) {
|
|
|
- await tx.$executeRaw`${Prisma.raw(
|
|
|
- `CREATE OR REPLACE TEMPORARY VIEW "${table}" AS SELECT t.* FROM "public"."${table}" t INNER JOIN "recurring_invoices" ri ON t."recurringInvoiceId" = ri.id`
|
|
|
- )}`
|
|
|
+ let failed = false
|
|
|
+ try {
|
|
|
+ await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY')
|
|
|
+ const rows = await tx.$queryRawUnsafe(sql)
|
|
|
+ return Array.isArray(rows) ? rows.slice(0, MAX_ROWS) : []
|
|
|
+ } catch (err) {
|
|
|
+ failed = true
|
|
|
+ throw err
|
|
|
+ } finally {
|
|
|
+ // Postgres allows DISCARD TEMP in a read-only transaction (DROP VIEW it
|
|
|
+ // does not). A failed statement leaves the transaction aborted; Prisma
|
|
|
+ // rolls it back and the rollback drops the views with it, so cleaning
|
|
|
+ // up there would only replace the real error message.
|
|
|
+ if (!failed) await tx.$executeRawUnsafe('DISCARD TEMP')
|
|
|
}
|
|
|
-
|
|
|
- // Children of inspections (via inspectionId → inspections.id)
|
|
|
- await tx.$executeRaw`${Prisma.raw(
|
|
|
- `CREATE OR REPLACE TEMPORARY VIEW "inspection_items" AS SELECT t.* FROM "public"."inspection_items" t INNER JOIN "inspections" i ON t."inspectionId" = i.id`
|
|
|
- )}`
|
|
|
-
|
|
|
- // 5. NOW set transaction read-only — the AI query cannot mutate anything
|
|
|
- await tx.$executeRawUnsafe('SET TRANSACTION READ ONLY')
|
|
|
-
|
|
|
- // 6. Execute the AI's query against the temp views
|
|
|
- const rows = await tx.$queryRawUnsafe(sql)
|
|
|
- return Array.isArray(rows) ? rows.slice(0, MAX_ROWS) : []
|
|
|
})
|
|
|
}
|
|
|
|
|
|
@@ -383,6 +681,7 @@ IMPORTANT business rules:
|
|
|
|
|
|
Note: camelCase columns MUST be double-quoted in SQL (e.g. "licensePlate", "totalAmount").
|
|
|
Organization filtering is applied automatically — do NOT add WHERE "organizationId" = ... yourself.
|
|
|
+Join tables with explicit JOIN ... ON. Comma-separated tables in FROM (FROM a, b) are rejected, as are LATERAL, TABLESAMPLE and row locks. CTEs (WITH name AS (...)) are allowed.
|
|
|
|
|
|
IMPORTANT: Linking to pages
|
|
|
Always include id columns in your queries — for the primary entity AND any related entities (vehicle, customer, etc.). When displaying results, add markdown links for ALL entities present in the row. Use these URL patterns:
|