useTestQueryRLS.ts200 lines · main
| 1 | import { type SafeSqlFragment, type UntrustedSqlFragment } from '@supabase/pg-meta' |
| 2 | import { useState } from 'react' |
| 3 | import { toast } from 'sonner' |
| 4 | |
| 5 | import { checkIfAppendLimitRequired, suffixWithLimit } from '../../SQLEditor/SQLEditor.utils' |
| 6 | import { type ParseQueryResults } from './RLSTester.types' |
| 7 | import { filterTablePolicies } from './useTestQueryRLS.utils' |
| 8 | import { useParseClientCodeMutation } from '@/data/ai/parse-client-code-mutation' |
| 9 | import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query' |
| 10 | import { useCheckTableRLSStatusMutation } from '@/data/database/table-check-rls-mutation' |
| 11 | import { useParseSQLQueryMutation } from '@/data/misc/parse-query-mutation' |
| 12 | import { useExecuteSqlMutation } from '@/data/sql/execute-sql-mutation' |
| 13 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 14 | import { wrapWithRoleImpersonation } from '@/lib/role-impersonation' |
| 15 | import { usePostgresSandbox } from '@/state/postgres-sandbox/sandbox' |
| 16 | import { |
| 17 | isRoleImpersonationEnabled, |
| 18 | useGetImpersonatedRoleState, |
| 19 | useImpersonatedUser, |
| 20 | useRoleImpersonationStateSnapshot, |
| 21 | } from '@/state/role-impersonation-state' |
| 22 | |
| 23 | const limit = 100 |
| 24 | |
| 25 | /** |
| 26 | * [Joshen] Testing a SQL query for its RLS access involves 3 async steps |
| 27 | * 0. (Optional) Inferring client library code to SQL query via the AI Assistant |
| 28 | * 1. Parsing the provided SQL query to retrieve its operation type + tables involved |
| 29 | * 2. Checking for tables involved if they've got RLS enabled |
| 30 | * 3. Actually running the query to retrieve the results |
| 31 | * |
| 32 | * Errors should all be handled as part of the UI instead of toasts, hence the empty onError |
| 33 | * handlers to mute the default error handlers within the react query mutationhooks |
| 34 | */ |
| 35 | export const useTestQueryRLS = () => { |
| 36 | const { data: project } = useSelectedProjectQuery() |
| 37 | const { role } = useRoleImpersonationStateSnapshot() |
| 38 | |
| 39 | const { sandbox } = usePostgresSandbox() |
| 40 | const getImpersonatedRoleState = useGetImpersonatedRoleState() |
| 41 | const impersonatedRoleState = getImpersonatedRoleState() |
| 42 | const user = useImpersonatedUser() |
| 43 | |
| 44 | const [isLoading, setIsLoading] = useState(false) |
| 45 | const [sandboxError, setSandboxError] = useState<Error>() |
| 46 | |
| 47 | const { data: policies = [] } = useDatabasePoliciesQuery({ |
| 48 | projectRef: project?.ref, |
| 49 | connectionString: project?.connectionString, |
| 50 | }) |
| 51 | |
| 52 | const { mutateAsync: executeSql, error: executeSqlMutationError } = useExecuteSqlMutation({ |
| 53 | onError: () => {}, |
| 54 | }) |
| 55 | const executeSqlError = sandbox ? sandboxError : executeSqlMutationError |
| 56 | |
| 57 | const { |
| 58 | mutateAsync: parseClientCode, |
| 59 | isPending: isInferring, |
| 60 | error: parseClientCodeError, |
| 61 | } = useParseClientCodeMutation({ |
| 62 | onError: () => {}, |
| 63 | }) |
| 64 | |
| 65 | const inferSQLFromLib = async ( |
| 66 | value: string, |
| 67 | onInferSQL: (unchecked_sql: UntrustedSqlFragment) => void |
| 68 | ) => { |
| 69 | const { unchecked_sql, valid } = await parseClientCode({ code: value }) |
| 70 | if (valid && unchecked_sql != null) { |
| 71 | onInferSQL(unchecked_sql) |
| 72 | } else { |
| 73 | toast.error('Client library code provided is not valid') |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | const { mutateAsync: parseQuery, error: parseQueryError } = useParseSQLQueryMutation({ |
| 78 | onError: () => {}, |
| 79 | }) |
| 80 | |
| 81 | const { mutateAsync: getTableRLSStatus, error: getTableRLSStatusError } = |
| 82 | useCheckTableRLSStatusMutation({ |
| 83 | onError: () => {}, |
| 84 | }) |
| 85 | |
| 86 | const testQuery = async ({ |
| 87 | value, |
| 88 | option, |
| 89 | onExecuteSQL, |
| 90 | onParseQuery, |
| 91 | }: { |
| 92 | value: SafeSqlFragment |
| 93 | option: 'anon' | 'authenticated' |
| 94 | onExecuteSQL: ({ |
| 95 | result, |
| 96 | isAutoLimit, |
| 97 | }: { |
| 98 | result: Object[] | null |
| 99 | isAutoLimit: boolean |
| 100 | }) => void |
| 101 | onParseQuery: (results?: ParseQueryResults) => void |
| 102 | }) => { |
| 103 | if (!project) return console.error('Project is required') |
| 104 | |
| 105 | if (option === 'authenticated' && !user) { |
| 106 | return toast('Select which user to test as before running the query') |
| 107 | } |
| 108 | |
| 109 | try { |
| 110 | setIsLoading(true) |
| 111 | setSandboxError(undefined) |
| 112 | |
| 113 | const { appendAutoLimit } = checkIfAppendLimitRequired(value, limit) |
| 114 | const formattedSql = suffixWithLimit(value, limit) |
| 115 | const data = await parseQuery({ sql: formattedSql }) |
| 116 | |
| 117 | if (data.operation !== 'SELECT') { |
| 118 | return toast('Only SELECT statements are supported with the RLS Tester at the moment') |
| 119 | } |
| 120 | |
| 121 | const formattedTables = data.tables.map((x) => { |
| 122 | const [schema, table] = x.includes('.') ? x.split('.') : ['public', x] |
| 123 | return { schema, table } |
| 124 | }) |
| 125 | const response = await getTableRLSStatus({ |
| 126 | projectRef: project?.ref, |
| 127 | connectionString: project?.connectionString, |
| 128 | tables: formattedTables, |
| 129 | }) |
| 130 | |
| 131 | const tables = response |
| 132 | .map(({ table, schema, rls_enabled }) => { |
| 133 | const tablePolicies = filterTablePolicies({ |
| 134 | policies, |
| 135 | schema, |
| 136 | table, |
| 137 | role: role?.role, |
| 138 | operation: data.operation, |
| 139 | }) |
| 140 | return { |
| 141 | table, |
| 142 | schema, |
| 143 | isRLSEnabled: rls_enabled, |
| 144 | tablePolicies, |
| 145 | } |
| 146 | }) |
| 147 | .sort((a, b) => { |
| 148 | const aFirst = a.isRLSEnabled && a.tablePolicies.length === 0 |
| 149 | const bFirst = b.isRLSEnabled && b.tablePolicies.length === 0 |
| 150 | return Number(bFirst) - Number(aFirst) |
| 151 | }) |
| 152 | |
| 153 | const autoLimit = appendAutoLimit ? limit : undefined |
| 154 | const sql = wrapWithRoleImpersonation(formattedSql, impersonatedRoleState) |
| 155 | |
| 156 | const { result } = sandbox |
| 157 | ? await sandbox.run({ sql }).catch((e) => { |
| 158 | setSandboxError(e instanceof Error ? e : new Error(String(e))) |
| 159 | throw e |
| 160 | }) |
| 161 | : await executeSql({ |
| 162 | sql, |
| 163 | autoLimit, |
| 164 | projectRef: project.ref, |
| 165 | connectionString: project.connectionString, |
| 166 | isRoleImpersonationEnabled: isRoleImpersonationEnabled(impersonatedRoleState.role), |
| 167 | isStatementTimeoutDisabled: true, |
| 168 | handleError: (e) => { |
| 169 | throw e |
| 170 | }, |
| 171 | queryKey: ['rls-tester'], |
| 172 | }) |
| 173 | onExecuteSQL({ result, isAutoLimit: !!autoLimit }) |
| 174 | |
| 175 | onParseQuery({ |
| 176 | tables, |
| 177 | operation: data.operation, |
| 178 | role: role?.role, |
| 179 | user, |
| 180 | }) |
| 181 | } catch (error) { |
| 182 | onExecuteSQL({ result: null, isAutoLimit: false }) |
| 183 | onParseQuery(undefined) |
| 184 | } finally { |
| 185 | setIsLoading(false) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | return { |
| 190 | limit, |
| 191 | testQuery, |
| 192 | inferSQLFromLib, |
| 193 | isLoading, |
| 194 | isInferring, |
| 195 | executeSqlError, |
| 196 | parseQueryError, |
| 197 | parseClientCodeError, |
| 198 | getTableRLSStatusError, |
| 199 | } |
| 200 | } |