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