Policies.utils.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  1. import {
  2. acceptUntrustedSql,
  3. ident,
  4. safeSql,
  5. untrustedSql,
  6. type DisplayableSqlFragment,
  7. type SafeSqlFragment,
  8. } from '@supabase/pg-meta'
  9. import type { PGPolicy } from '@supabase/pg-meta'
  10. import { has, isEmpty, isEqual } from 'lodash'
  11. import {
  12. DraftPostgresPolicyCreatePayload,
  13. DraftPostgresPolicyUpdatePayload,
  14. PolicyFormField,
  15. PolicyForReview,
  16. } from './Policies.types'
  17. import { generateSqlPolicy } from '@/data/ai/sql-policy-mutation'
  18. import type { CreatePolicyBody } from '@/data/database-policies/database-policy-create-mutation'
  19. import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query'
  20. /**
  21. * Returns an array of SQL statements that will preview in the review step of the policy editor
  22. * @param {*} policyFormFields { name, using, check, command }
  23. */
  24. export const createSQLPolicy = (
  25. policyFormFields: PolicyFormField,
  26. originalPolicyFormFields?: PGPolicy
  27. ) => {
  28. const { definition, check } = policyFormFields
  29. const formattedPolicyFormFields = {
  30. ...policyFormFields,
  31. definition: definition
  32. ? definition.replace(/\s+/g, ' ').trim()
  33. : definition === undefined
  34. ? null
  35. : definition,
  36. check: check ? check.replace(/\s+/g, ' ').trim() : check === undefined ? null : check,
  37. }
  38. if (!originalPolicyFormFields || isEmpty(originalPolicyFormFields)) {
  39. return createSQLStatementForCreatePolicy(formattedPolicyFormFields)
  40. }
  41. // If there are no changes, return an empty object
  42. if (isEqual(policyFormFields, originalPolicyFormFields)) {
  43. return {}
  44. }
  45. // Extract out all the fields that updated
  46. const fieldsToUpdate: any = {}
  47. if (!isEqual(formattedPolicyFormFields.name, originalPolicyFormFields.name)) {
  48. fieldsToUpdate.name = formattedPolicyFormFields.name
  49. }
  50. if (!isEqual(formattedPolicyFormFields.definition, originalPolicyFormFields.definition)) {
  51. fieldsToUpdate.definition = formattedPolicyFormFields.definition
  52. }
  53. if (!isEqual(formattedPolicyFormFields.check, originalPolicyFormFields.check)) {
  54. fieldsToUpdate.check = formattedPolicyFormFields.check
  55. }
  56. if (!isEqual(formattedPolicyFormFields.roles, originalPolicyFormFields.roles)) {
  57. fieldsToUpdate.roles = formattedPolicyFormFields.roles
  58. }
  59. if (!isEmpty(fieldsToUpdate)) {
  60. return createSQLStatementForUpdatePolicy(formattedPolicyFormFields, fieldsToUpdate)
  61. }
  62. return {}
  63. }
  64. const createSQLStatementForCreatePolicy = (policyFormFields: PolicyFormField): PolicyForReview => {
  65. const { name, definition, check, command, schema, table } = policyFormFields
  66. const roles = policyFormFields.roles.length === 0 ? ['public'] : policyFormFields.roles
  67. const description = `Add policy for the ${command} operation under the policy "${name}"`
  68. const statement = [
  69. `CREATE POLICY "${name}" ON "${schema}"."${table}"`,
  70. `AS PERMISSIVE FOR ${command}`,
  71. `TO ${roles.join(', ')}`,
  72. `${definition ? `USING (${definition})` : ''}`,
  73. `${check ? `WITH CHECK (${check})` : ''}`,
  74. ].join('\n')
  75. return { description, statement }
  76. }
  77. const createSQLStatementForUpdatePolicy = (
  78. policyFormFields: PolicyFormField,
  79. fieldsToUpdate: Partial<PolicyFormField>
  80. ): PolicyForReview => {
  81. const { name, schema, table } = policyFormFields
  82. const definitionChanged = has(fieldsToUpdate, ['definition'])
  83. const checkChanged = has(fieldsToUpdate, ['check'])
  84. const nameChanged = has(fieldsToUpdate, ['name'])
  85. const rolesChanged = has(fieldsToUpdate, ['roles'])
  86. const parameters = Object.keys(fieldsToUpdate)
  87. const description = `Update policy's ${
  88. parameters.length === 1
  89. ? parameters[0]
  90. : `${parameters.slice(0, parameters.length - 1).join(', ')} and ${
  91. parameters[parameters.length - 1]
  92. }`
  93. } `
  94. const roles =
  95. (fieldsToUpdate?.roles ?? []).length === 0 ? ['public'] : (fieldsToUpdate.roles as string[])
  96. const alterStatement = `ALTER POLICY "${name}" ON "${schema}"."${table}"`
  97. const statement = [
  98. 'BEGIN;',
  99. ...(definitionChanged ? [` ${alterStatement} USING (${fieldsToUpdate.definition});`] : []),
  100. ...(checkChanged ? [` ${alterStatement} WITH CHECK (${fieldsToUpdate.check});`] : []),
  101. ...(rolesChanged ? [` ${alterStatement} TO ${roles.join(', ')};`] : []),
  102. ...(nameChanged ? [` ${alterStatement} RENAME TO "${fieldsToUpdate.name}";`] : []),
  103. 'COMMIT;',
  104. ].join('\n')
  105. return { description, statement }
  106. }
  107. // These constructors return DRAFT payloads — `definition`/`check` are still
  108. // `DisplayableSqlFragment`. Promotion to `SafeSqlFragment` must happen at the user gesture
  109. // (the Save click in `PolicyEditorModal`), not here, since this module has no guarantee that
  110. // it was reached via a deliberate user action.
  111. export const createPayloadForCreatePolicy = (
  112. policyFormFields: PolicyFormField
  113. ): DraftPostgresPolicyCreatePayload => {
  114. const { name, schema, table, command, definition, check, roles } = policyFormFields
  115. return {
  116. name,
  117. schema,
  118. table,
  119. action: 'PERMISSIVE',
  120. command: command || undefined,
  121. definition: !definition ? undefined : untrustedSql(definition),
  122. check: !check ? undefined : untrustedSql(check),
  123. roles: roles.length > 0 ? roles : undefined,
  124. }
  125. }
  126. export const createPayloadForUpdatePolicy = (
  127. policyFormFields: PolicyFormField,
  128. originalPolicyFormFields: PGPolicy
  129. ): DraftPostgresPolicyUpdatePayload => {
  130. const { definition, check } = policyFormFields
  131. const formattedDefinition = definition ? definition.replace(/\s+/g, ' ').trim() : definition
  132. const formattedCheck = check ? check.replace(/\s+/g, ' ').trim() : check
  133. const payload: DraftPostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id }
  134. if (!isEqual(policyFormFields.name, originalPolicyFormFields.name)) {
  135. payload.name = policyFormFields.name
  136. }
  137. if (!isEqual(formattedDefinition, originalPolicyFormFields.definition)) {
  138. payload.definition = !formattedDefinition ? undefined : untrustedSql(formattedDefinition)
  139. }
  140. if (!isEqual(formattedCheck, originalPolicyFormFields.check)) {
  141. payload.check = !formattedCheck ? undefined : untrustedSql(formattedCheck)
  142. }
  143. if (!isEqual(policyFormFields.roles, originalPolicyFormFields.roles)) {
  144. if (policyFormFields.roles.length === 0) payload.roles = ['public']
  145. else payload.roles = policyFormFields.roles || undefined
  146. }
  147. return payload
  148. }
  149. // --- Policy Generation ---
  150. /**
  151. * A policy generated for display/staging in the table editor.
  152. * `definition`/`check` are `DisplayableSqlFragment` because generators have different provenance:
  153. * programmatic generation produces `SafeSqlFragment` (composed via `safeSql`), AI generation
  154. * produces `UntrustedSqlFragment` (third-party output). Consumers must promote via
  155. * `acceptUntrustedSql` at a user gesture before executing.
  156. */
  157. export type GeneratedPolicy = Required<
  158. Pick<CreatePolicyBody, 'name' | 'table' | 'schema' | 'action' | 'roles'>
  159. > &
  160. Pick<CreatePolicyBody, 'command'> & {
  161. definition?: DisplayableSqlFragment
  162. check?: DisplayableSqlFragment
  163. sql: string
  164. }
  165. /**
  166. * A {@link GeneratedPolicy} whose `definition`/`check` have already been promoted to
  167. * `SafeSqlFragment`. Producing one of these is the contract that says: the user gesture
  168. * required to execute this SQL has already happened.
  169. */
  170. export type AcceptedGeneratedPolicy = Omit<GeneratedPolicy, 'definition' | 'check'> & {
  171. definition?: SafeSqlFragment
  172. check?: SafeSqlFragment
  173. }
  174. /**
  175. * Promotes a {@link GeneratedPolicy} to an {@link AcceptedGeneratedPolicy}.
  176. * ONLY call from an event handler tied to a deliberate user action (e.g. the Save click
  177. * on the table editor). Never call from useEffect, render, or any path that runs without
  178. * a user gesture.
  179. */
  180. export const acceptGeneratedPolicy = (policy: GeneratedPolicy): AcceptedGeneratedPolicy => ({
  181. ...policy,
  182. definition: policy.definition === undefined ? undefined : acceptUntrustedSql(policy.definition),
  183. check: policy.check === undefined ? undefined : acceptUntrustedSql(policy.check),
  184. })
  185. type Relationship = {
  186. source_schema: string
  187. source_table_name: string
  188. source_column_name: string
  189. target_table_schema: string
  190. target_table_name: string
  191. target_column_name: string
  192. }
  193. /**
  194. * Gets relationships for a specific table from FK constraints.
  195. * Returns relationships where the table is the source.
  196. */
  197. const getRelationshipsForTable = ({
  198. schema,
  199. table,
  200. fkConstraints,
  201. }: {
  202. schema: string
  203. table: string
  204. fkConstraints: ForeignKeyConstraint[]
  205. }): Relationship[] => {
  206. return fkConstraints
  207. .filter((fk) => fk.source_schema === schema && fk.source_table === table)
  208. .flatMap((fk) =>
  209. fk.source_columns.map((sourceCol, i) => ({
  210. source_schema: fk.source_schema,
  211. source_table_name: fk.source_table,
  212. source_column_name: sourceCol,
  213. target_table_schema: fk.target_schema,
  214. target_table_name: fk.target_table,
  215. target_column_name: fk.target_columns[i],
  216. }))
  217. )
  218. }
  219. /**
  220. * BFS to find shortest path from table to auth.users via foreign key relationships.
  221. * Returns null if no path exists within maxDepth.
  222. */
  223. const findPathToAuthUsers = (
  224. startTable: { schema: string; name: string },
  225. allForeignKeyConstraints: ForeignKeyConstraint[],
  226. maxDepth = 3
  227. ): Relationship[] | null => {
  228. const startRelationships = getRelationshipsForTable({
  229. schema: startTable.schema,
  230. table: startTable.name,
  231. fkConstraints: allForeignKeyConstraints,
  232. })
  233. const queue: { table: { schema: string; name: string }; path: Relationship[] }[] = [
  234. { table: startTable, path: [] },
  235. ]
  236. const visited = new Set<string>()
  237. visited.add(`${startTable.schema}.${startTable.name}`)
  238. while (queue.length > 0) {
  239. const queueItem = queue.shift()
  240. if (!queueItem) continue
  241. const { table, path } = queueItem
  242. if (path.length >= maxDepth) continue
  243. const relationships =
  244. path.length === 0
  245. ? startRelationships
  246. : getRelationshipsForTable({
  247. schema: table.schema,
  248. table: table.name,
  249. fkConstraints: allForeignKeyConstraints,
  250. })
  251. for (const rel of relationships) {
  252. // Found path to auth.users
  253. if (
  254. rel.target_table_schema === 'auth' &&
  255. rel.target_table_name === 'users' &&
  256. rel.target_column_name === 'id'
  257. ) {
  258. return [...path, rel]
  259. }
  260. const targetId = `${rel.target_table_schema}.${rel.target_table_name}`
  261. if (visited.has(targetId)) continue
  262. // Add target table to queue for further exploration
  263. queue.push({
  264. table: { schema: rel.target_table_schema, name: rel.target_table_name },
  265. path: [...path, rel],
  266. })
  267. visited.add(targetId)
  268. }
  269. }
  270. return null
  271. }
  272. /** Generates SQL expression for RLS policy based on FK path to auth.users */
  273. const buildPolicyExpression = (path: Relationship[]): SafeSqlFragment => {
  274. if (path.length === 0) return safeSql``
  275. // Direct FK to auth.users
  276. if (path.length === 1) {
  277. return safeSql`(select auth.uid()) = ${ident(path[0].source_column_name)}`
  278. }
  279. // Indirect path - build EXISTS with JOINs
  280. const [first, ...rest] = path
  281. const firstTarget = safeSql`${ident(first.target_table_schema)}.${ident(first.target_table_name)}`
  282. const source = safeSql`${ident(first.source_schema)}.${ident(first.source_table_name)}`
  283. const last = path[path.length - 1]
  284. const joins = rest.slice(0, -1).reduce<SafeSqlFragment>(
  285. (acc, r) => {
  286. const targetSchema = ident(r.target_table_schema)
  287. const targetTable = ident(r.target_table_name)
  288. const targetColumn = ident(r.target_column_name)
  289. const sourceSchema = ident(r.source_schema)
  290. const sourceTable = ident(r.source_table_name)
  291. const sourceColumn = ident(r.source_column_name)
  292. const join = safeSql`join ${targetSchema}.${targetTable} on ${targetSchema}.${targetTable}.${targetColumn} = ${sourceSchema}.${sourceTable}.${sourceColumn}`
  293. return acc.length === 0 ? join : safeSql`${acc}\n ${join}`
  294. },
  295. safeSql``
  296. )
  297. return safeSql`exists (
  298. select 1 from ${firstTarget}
  299. ${joins}
  300. where ${firstTarget}.${ident(first.target_column_name)} = ${source}.${ident(first.source_column_name)}
  301. and ${ident(last.source_schema)}.${ident(last.source_table_name)}.${ident(last.source_column_name)} = (select auth.uid())
  302. )`
  303. }
  304. /** Builds policy SQL for all CRUD operations */
  305. const buildPoliciesForPath = (
  306. table: { name: string; schema: string },
  307. path: Relationship[]
  308. ): GeneratedPolicy[] => {
  309. const expression = buildPolicyExpression(path)
  310. const targetCol = path[0].source_column_name
  311. return (['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const).map((command) => {
  312. const name = `Enable ${command.toLowerCase()} access for users based on ${ident(targetCol)}`
  313. const base = `CREATE POLICY "${name}" ON ${ident(table.schema)}.${ident(table.name)} AS PERMISSIVE FOR ${command} TO authenticated`
  314. const sql =
  315. command === 'INSERT'
  316. ? `${base} WITH CHECK (${expression});`
  317. : command === 'UPDATE'
  318. ? `${base} USING (${expression}) WITH CHECK (${expression});`
  319. : `${base} USING (${expression});`
  320. // Structured data for mutation API
  321. const definition = command === 'INSERT' ? undefined : expression
  322. const check = command === 'SELECT' || command === 'DELETE' ? undefined : expression
  323. return {
  324. name,
  325. sql,
  326. command,
  327. table: table.name,
  328. schema: table.schema,
  329. definition,
  330. check,
  331. action: 'PERMISSIVE' as const,
  332. roles: ['authenticated'],
  333. }
  334. })
  335. }
  336. /**
  337. * Generates RLS policies programmatically based on FK relationships to auth.users.
  338. */
  339. export const generateProgrammaticPoliciesForTable = ({
  340. table,
  341. foreignKeyConstraints,
  342. }: {
  343. table: { name: string; schema: string }
  344. foreignKeyConstraints: ForeignKeyConstraint[]
  345. }): GeneratedPolicy[] => {
  346. try {
  347. const path = findPathToAuthUsers(table, foreignKeyConstraints)
  348. if (path?.length) {
  349. return buildPoliciesForPath(table, path)
  350. }
  351. } catch (error) {
  352. // Silently fail - caller will handle empty result
  353. }
  354. return []
  355. }
  356. /**
  357. * Generates RLS policies using AI.
  358. */
  359. export const generateAiPoliciesForTable = async ({
  360. table,
  361. columns,
  362. projectRef,
  363. connectionString,
  364. }: {
  365. table: { name: string; schema: string }
  366. columns: { name: string }[]
  367. projectRef: string
  368. connectionString?: string | null
  369. }): Promise<GeneratedPolicy[]> => {
  370. if (!connectionString) return []
  371. try {
  372. return await generateSqlPolicy({
  373. tableName: table.name,
  374. schema: table.schema,
  375. columns: columns.map((col) => col.name.trim()),
  376. projectRef,
  377. connectionString: connectionString ?? '',
  378. })
  379. } catch (error) {
  380. console.log('AI policy generation failed:', error)
  381. return []
  382. }
  383. }
  384. /**
  385. * Generates RLS policies for a table.
  386. * First tries programmatic generation based on FK relationships to auth.users.
  387. * Falls back to AI generation if no path exists.
  388. */
  389. export const generateStartingPoliciesForTable = async ({
  390. table,
  391. foreignKeyConstraints,
  392. columns,
  393. projectRef,
  394. connectionString,
  395. enableAi,
  396. }: {
  397. table: { name: string; schema: string }
  398. foreignKeyConstraints: ForeignKeyConstraint[]
  399. columns: { name: string }[]
  400. projectRef: string
  401. connectionString?: string | null
  402. enableAi: boolean
  403. }): Promise<GeneratedPolicy[]> => {
  404. // Try programmatic generation first
  405. const programmaticPolicies = generateProgrammaticPoliciesForTable({
  406. table,
  407. foreignKeyConstraints,
  408. })
  409. if (programmaticPolicies.length > 0) {
  410. return programmaticPolicies
  411. }
  412. // Fall back to AI generation
  413. if (enableAi) {
  414. return await generateAiPoliciesForTable({
  415. table,
  416. columns,
  417. projectRef,
  418. connectionString,
  419. })
  420. }
  421. return []
  422. }