mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Refactor / dependency cleanup. ## What is the current behavior? `apps/studio` lists both `@supabase/pg-meta` (workspace package) as a runtime dep and `@supabase/postgres-meta` (external npm package, `^0.64.4`) as a devDependency. The external package is used only for type imports across 44 files — there is no runtime usage and no codegen pipeline that needs it. ## What is the new behavior? Every `Postgres*` type import (`PostgresTable`, `PostgresColumn`, `PostgresPolicy`, `PostgresTrigger`, `PostgresView`, `PostgresMaterializedView`, `PostgresForeignTable`, `PostgresSchema`, `PostgresPublication`, `PostgresRelationship`, `PostgresPrimaryKey`) is replaced with its `PG*` counterpart from `@supabase/pg-meta`, and the external dep is removed from \`apps/studio/package.json\`. Top-level type re-exports were added to \`packages/pg-meta/src/index.ts\` so consumers can import directly from the package root. Two latent issues surfaced by the stricter pg-meta types are also fixed: - \`data/foreign-tables/foreign-tables-query.ts\` was casting foreign-table results as \`PostgresView[]\`; corrected to \`PGForeignTable[]\`. - \`pg-meta\`'s \`PGTrigger\` Zod schema declared \`orientation\`/\`activation\` as \`z.string()\`, inconsistent with pg-meta's own \`getDatabaseTriggerUpdateSQL\` helper that requires the narrow literal unions; tightened to \`z.enum\`. ## Additional context <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Chores** * Updated internal TypeScript type definitions across the codebase to use the latest type system from `@supabase/pg-meta`. * Removed `@supabase/postgres-meta` dependency. * Enhanced type validation for database triggers and schemas to enforce stricter constraints. [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45844) <!-- end of auto-generated comment: release notes by coderabbit.ai -->
443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
import type { PGPolicy } from '@supabase/pg-meta'
|
|
import { ident } from '@supabase/pg-meta/src/pg-format'
|
|
import { has, isEmpty, isEqual } from 'lodash'
|
|
|
|
import {
|
|
PolicyFormField,
|
|
PolicyForReview,
|
|
PostgresPolicyCreatePayload,
|
|
PostgresPolicyUpdatePayload,
|
|
} from './Policies.types'
|
|
import { generateSqlPolicy } from '@/data/ai/sql-policy-mutation'
|
|
import type { CreatePolicyBody } from '@/data/database-policies/database-policy-create-mutation'
|
|
import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query'
|
|
|
|
/**
|
|
* Returns an array of SQL statements that will preview in the review step of the policy editor
|
|
* @param {*} policyFormFields { name, using, check, command }
|
|
*/
|
|
|
|
export const createSQLPolicy = (
|
|
policyFormFields: PolicyFormField,
|
|
originalPolicyFormFields?: PGPolicy
|
|
) => {
|
|
const { definition, check } = policyFormFields
|
|
const formattedPolicyFormFields = {
|
|
...policyFormFields,
|
|
definition: definition
|
|
? definition.replace(/\s+/g, ' ').trim()
|
|
: definition === undefined
|
|
? null
|
|
: definition,
|
|
check: check ? check.replace(/\s+/g, ' ').trim() : check === undefined ? null : check,
|
|
}
|
|
|
|
if (!originalPolicyFormFields || isEmpty(originalPolicyFormFields)) {
|
|
return createSQLStatementForCreatePolicy(formattedPolicyFormFields)
|
|
}
|
|
|
|
// If there are no changes, return an empty object
|
|
if (isEqual(policyFormFields, originalPolicyFormFields)) {
|
|
return {}
|
|
}
|
|
|
|
// Extract out all the fields that updated
|
|
const fieldsToUpdate: any = {}
|
|
if (!isEqual(formattedPolicyFormFields.name, originalPolicyFormFields.name)) {
|
|
fieldsToUpdate.name = formattedPolicyFormFields.name
|
|
}
|
|
if (!isEqual(formattedPolicyFormFields.definition, originalPolicyFormFields.definition)) {
|
|
fieldsToUpdate.definition = formattedPolicyFormFields.definition
|
|
}
|
|
if (!isEqual(formattedPolicyFormFields.check, originalPolicyFormFields.check)) {
|
|
fieldsToUpdate.check = formattedPolicyFormFields.check
|
|
}
|
|
if (!isEqual(formattedPolicyFormFields.roles, originalPolicyFormFields.roles)) {
|
|
fieldsToUpdate.roles = formattedPolicyFormFields.roles
|
|
}
|
|
|
|
if (!isEmpty(fieldsToUpdate)) {
|
|
return createSQLStatementForUpdatePolicy(formattedPolicyFormFields, fieldsToUpdate)
|
|
}
|
|
|
|
return {}
|
|
}
|
|
|
|
const createSQLStatementForCreatePolicy = (policyFormFields: PolicyFormField): PolicyForReview => {
|
|
const { name, definition, check, command, schema, table } = policyFormFields
|
|
const roles = policyFormFields.roles.length === 0 ? ['public'] : policyFormFields.roles
|
|
const description = `Add policy for the ${command} operation under the policy "${name}"`
|
|
const statement = [
|
|
`CREATE POLICY "${name}" ON "${schema}"."${table}"`,
|
|
`AS PERMISSIVE FOR ${command}`,
|
|
`TO ${roles.join(', ')}`,
|
|
`${definition ? `USING (${definition})` : ''}`,
|
|
`${check ? `WITH CHECK (${check})` : ''}`,
|
|
].join('\n')
|
|
|
|
return { description, statement }
|
|
}
|
|
|
|
const createSQLStatementForUpdatePolicy = (
|
|
policyFormFields: PolicyFormField,
|
|
fieldsToUpdate: Partial<PolicyFormField>
|
|
): PolicyForReview => {
|
|
const { name, schema, table } = policyFormFields
|
|
|
|
const definitionChanged = has(fieldsToUpdate, ['definition'])
|
|
const checkChanged = has(fieldsToUpdate, ['check'])
|
|
const nameChanged = has(fieldsToUpdate, ['name'])
|
|
const rolesChanged = has(fieldsToUpdate, ['roles'])
|
|
|
|
const parameters = Object.keys(fieldsToUpdate)
|
|
const description = `Update policy's ${
|
|
parameters.length === 1
|
|
? parameters[0]
|
|
: `${parameters.slice(0, parameters.length - 1).join(', ')} and ${
|
|
parameters[parameters.length - 1]
|
|
}`
|
|
} `
|
|
const roles =
|
|
(fieldsToUpdate?.roles ?? []).length === 0 ? ['public'] : (fieldsToUpdate.roles as string[])
|
|
|
|
const alterStatement = `ALTER POLICY "${name}" ON "${schema}"."${table}"`
|
|
const statement = [
|
|
'BEGIN;',
|
|
...(definitionChanged ? [` ${alterStatement} USING (${fieldsToUpdate.definition});`] : []),
|
|
...(checkChanged ? [` ${alterStatement} WITH CHECK (${fieldsToUpdate.check});`] : []),
|
|
...(rolesChanged ? [` ${alterStatement} TO ${roles.join(', ')};`] : []),
|
|
...(nameChanged ? [` ${alterStatement} RENAME TO "${fieldsToUpdate.name}";`] : []),
|
|
'COMMIT;',
|
|
].join('\n')
|
|
|
|
return { description, statement }
|
|
}
|
|
|
|
export const createPayloadForCreatePolicy = (
|
|
policyFormFields: PolicyFormField
|
|
): PostgresPolicyCreatePayload => {
|
|
const { command, definition, check, roles } = policyFormFields
|
|
return {
|
|
...policyFormFields,
|
|
action: 'PERMISSIVE',
|
|
command: command || undefined,
|
|
definition: definition || undefined,
|
|
check: check || undefined,
|
|
roles: roles.length > 0 ? roles : undefined,
|
|
}
|
|
}
|
|
|
|
export const createPayloadForUpdatePolicy = (
|
|
policyFormFields: PolicyFormField,
|
|
originalPolicyFormFields: PGPolicy
|
|
): PostgresPolicyUpdatePayload => {
|
|
const { definition, check } = policyFormFields
|
|
const formattedPolicyFormFields = {
|
|
...policyFormFields,
|
|
definition: definition ? definition.replace(/\s+/g, ' ').trim() : definition,
|
|
check: check ? check.replace(/\s+/g, ' ').trim() : check,
|
|
}
|
|
|
|
const payload: PostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id }
|
|
|
|
if (!isEqual(formattedPolicyFormFields.name, originalPolicyFormFields.name)) {
|
|
payload.name = formattedPolicyFormFields.name
|
|
}
|
|
if (!isEqual(formattedPolicyFormFields.definition, originalPolicyFormFields.definition)) {
|
|
payload.definition = formattedPolicyFormFields.definition || undefined
|
|
}
|
|
if (!isEqual(formattedPolicyFormFields.check, originalPolicyFormFields.check)) {
|
|
payload.check = formattedPolicyFormFields.check || undefined
|
|
}
|
|
if (!isEqual(formattedPolicyFormFields.roles, originalPolicyFormFields.roles)) {
|
|
if (formattedPolicyFormFields.roles.length === 0) payload.roles = ['public']
|
|
else payload.roles = formattedPolicyFormFields.roles || undefined
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
// --- Policy Generation ---
|
|
|
|
/**
|
|
* Generated policy extends CreatePolicyBody with additional fields for display.
|
|
* - sql: Full CREATE POLICY SQL statement for preview
|
|
* - Required fields that are optional in CreatePolicyBody
|
|
*/
|
|
export type GeneratedPolicy = Required<
|
|
Pick<CreatePolicyBody, 'name' | 'table' | 'schema' | 'action' | 'roles'>
|
|
> &
|
|
Pick<CreatePolicyBody, 'command' | 'definition' | 'check'> & {
|
|
sql: string
|
|
}
|
|
|
|
type Relationship = {
|
|
source_schema: string
|
|
source_table_name: string
|
|
source_column_name: string
|
|
target_table_schema: string
|
|
target_table_name: string
|
|
target_column_name: string
|
|
}
|
|
|
|
/**
|
|
* Gets relationships for a specific table from FK constraints.
|
|
* Returns relationships where the table is the source.
|
|
*/
|
|
const getRelationshipsForTable = ({
|
|
schema,
|
|
table,
|
|
fkConstraints,
|
|
}: {
|
|
schema: string
|
|
table: string
|
|
fkConstraints: ForeignKeyConstraint[]
|
|
}): Relationship[] => {
|
|
return fkConstraints
|
|
.filter((fk) => fk.source_schema === schema && fk.source_table === table)
|
|
.flatMap((fk) =>
|
|
fk.source_columns.map((sourceCol, i) => ({
|
|
source_schema: fk.source_schema,
|
|
source_table_name: fk.source_table,
|
|
source_column_name: sourceCol,
|
|
target_table_schema: fk.target_schema,
|
|
target_table_name: fk.target_table,
|
|
target_column_name: fk.target_columns[i],
|
|
}))
|
|
)
|
|
}
|
|
|
|
/**
|
|
* BFS to find shortest path from table to auth.users via foreign key relationships.
|
|
* Returns null if no path exists within maxDepth.
|
|
*/
|
|
const findPathToAuthUsers = (
|
|
startTable: { schema: string; name: string },
|
|
allForeignKeyConstraints: ForeignKeyConstraint[],
|
|
maxDepth = 3
|
|
): Relationship[] | null => {
|
|
const startRelationships = getRelationshipsForTable({
|
|
schema: startTable.schema,
|
|
table: startTable.name,
|
|
fkConstraints: allForeignKeyConstraints,
|
|
})
|
|
|
|
const queue: { table: { schema: string; name: string }; path: Relationship[] }[] = [
|
|
{ table: startTable, path: [] },
|
|
]
|
|
const visited = new Set<string>()
|
|
visited.add(`${startTable.schema}.${startTable.name}`)
|
|
|
|
while (queue.length > 0) {
|
|
const queueItem = queue.shift()
|
|
if (!queueItem) continue
|
|
|
|
const { table, path } = queueItem
|
|
if (path.length >= maxDepth) continue
|
|
|
|
const relationships =
|
|
path.length === 0
|
|
? startRelationships
|
|
: getRelationshipsForTable({
|
|
schema: table.schema,
|
|
table: table.name,
|
|
fkConstraints: allForeignKeyConstraints,
|
|
})
|
|
|
|
for (const rel of relationships) {
|
|
// Found path to auth.users
|
|
if (
|
|
rel.target_table_schema === 'auth' &&
|
|
rel.target_table_name === 'users' &&
|
|
rel.target_column_name === 'id'
|
|
) {
|
|
return [...path, rel]
|
|
}
|
|
|
|
const targetId = `${rel.target_table_schema}.${rel.target_table_name}`
|
|
if (visited.has(targetId)) continue
|
|
|
|
// Add target table to queue for further exploration
|
|
queue.push({
|
|
table: { schema: rel.target_table_schema, name: rel.target_table_name },
|
|
path: [...path, rel],
|
|
})
|
|
visited.add(targetId)
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
/** Generates SQL expression for RLS policy based on FK path to auth.users */
|
|
const buildPolicyExpression = (path: Relationship[]): string => {
|
|
if (path.length === 0) return ''
|
|
|
|
// Direct FK to auth.users
|
|
if (path.length === 1) {
|
|
return `(select auth.uid()) = ${ident(path[0].source_column_name)}`
|
|
}
|
|
|
|
// Indirect path - build EXISTS with JOINs
|
|
const [first, ...rest] = path
|
|
const firstTarget = `${ident(first.target_table_schema)}.${ident(first.target_table_name)}`
|
|
const source = `${ident(first.source_schema)}.${ident(first.source_table_name)}`
|
|
const last = path[path.length - 1]
|
|
|
|
const joins = rest
|
|
.slice(0, -1)
|
|
.map((r) => {
|
|
const targetSchema = ident(r.target_table_schema)
|
|
const targetTable = ident(r.target_table_name)
|
|
const targetColumn = ident(r.target_column_name)
|
|
|
|
const sourceSchema = ident(r.source_schema)
|
|
const sourceTable = ident(r.source_table_name)
|
|
const sourceColumn = ident(r.source_column_name)
|
|
return `join ${targetSchema}.${targetTable} on ${targetSchema}.${targetTable}.${targetColumn} = ${sourceSchema}.${sourceTable}.${sourceColumn}`
|
|
})
|
|
.join('\n ')
|
|
|
|
return `exists (
|
|
select 1 from ${firstTarget}
|
|
${joins}
|
|
where ${firstTarget}.${ident(first.target_column_name)} = ${source}.${ident(first.source_column_name)}
|
|
and ${ident(last.source_schema)}.${ident(last.source_table_name)}.${ident(last.source_column_name)} = (select auth.uid())
|
|
)`
|
|
}
|
|
|
|
/** Builds policy SQL for all CRUD operations */
|
|
const buildPoliciesForPath = (
|
|
table: { name: string; schema: string },
|
|
path: Relationship[]
|
|
): GeneratedPolicy[] => {
|
|
const expression = buildPolicyExpression(path)
|
|
const targetCol = path[0].source_column_name
|
|
|
|
return (['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const).map((command) => {
|
|
const name = `Enable ${command.toLowerCase()} access for users based on ${ident(targetCol)}`
|
|
const base = `CREATE POLICY "${name}" ON ${ident(table.schema)}.${ident(table.name)} AS PERMISSIVE FOR ${command} TO authenticated`
|
|
|
|
const sql =
|
|
command === 'INSERT'
|
|
? `${base} WITH CHECK (${expression});`
|
|
: command === 'UPDATE'
|
|
? `${base} USING (${expression}) WITH CHECK (${expression});`
|
|
: `${base} USING (${expression});`
|
|
|
|
// Structured data for mutation API
|
|
const definition = command === 'INSERT' ? undefined : expression
|
|
const check = command === 'SELECT' || command === 'DELETE' ? undefined : expression
|
|
|
|
return {
|
|
name,
|
|
sql,
|
|
command,
|
|
table: table.name,
|
|
schema: table.schema,
|
|
definition,
|
|
check,
|
|
action: 'PERMISSIVE' as const,
|
|
roles: ['authenticated'],
|
|
}
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Generates RLS policies programmatically based on FK relationships to auth.users.
|
|
*/
|
|
export const generateProgrammaticPoliciesForTable = ({
|
|
table,
|
|
foreignKeyConstraints,
|
|
}: {
|
|
table: { name: string; schema: string }
|
|
foreignKeyConstraints: ForeignKeyConstraint[]
|
|
}): GeneratedPolicy[] => {
|
|
try {
|
|
const path = findPathToAuthUsers(table, foreignKeyConstraints)
|
|
|
|
if (path?.length) {
|
|
return buildPoliciesForPath(table, path)
|
|
}
|
|
} catch (error) {
|
|
// Silently fail - caller will handle empty result
|
|
}
|
|
|
|
return []
|
|
}
|
|
|
|
/**
|
|
* Generates RLS policies using AI.
|
|
*/
|
|
export const generateAiPoliciesForTable = async ({
|
|
table,
|
|
columns,
|
|
projectRef,
|
|
connectionString,
|
|
}: {
|
|
table: { name: string; schema: string }
|
|
columns: { name: string }[]
|
|
projectRef: string
|
|
connectionString?: string | null
|
|
}): Promise<GeneratedPolicy[]> => {
|
|
if (!connectionString) return []
|
|
|
|
try {
|
|
const aiPolicies = await generateSqlPolicy({
|
|
tableName: table.name,
|
|
schema: table.schema,
|
|
columns: columns.map((col) => col.name.trim()),
|
|
projectRef,
|
|
connectionString: connectionString ?? '',
|
|
})
|
|
// AI response now includes all structured fields
|
|
return aiPolicies as GeneratedPolicy[]
|
|
} catch (error) {
|
|
console.log('AI policy generation failed:', error)
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generates RLS policies for a table.
|
|
* First tries programmatic generation based on FK relationships to auth.users.
|
|
* Falls back to AI generation if no path exists.
|
|
*/
|
|
export const generateStartingPoliciesForTable = async ({
|
|
table,
|
|
foreignKeyConstraints,
|
|
columns,
|
|
projectRef,
|
|
connectionString,
|
|
enableAi,
|
|
}: {
|
|
table: { name: string; schema: string }
|
|
foreignKeyConstraints: ForeignKeyConstraint[]
|
|
columns: { name: string }[]
|
|
projectRef: string
|
|
connectionString?: string | null
|
|
enableAi: boolean
|
|
}): Promise<GeneratedPolicy[]> => {
|
|
// Try programmatic generation first
|
|
const programmaticPolicies = generateProgrammaticPoliciesForTable({
|
|
table,
|
|
foreignKeyConstraints,
|
|
})
|
|
|
|
if (programmaticPolicies.length > 0) {
|
|
return programmaticPolicies
|
|
}
|
|
|
|
// Fall back to AI generation
|
|
if (enableAi) {
|
|
return await generateAiPoliciesForTable({
|
|
table,
|
|
columns,
|
|
projectRef,
|
|
connectionString,
|
|
})
|
|
}
|
|
|
|
return []
|
|
}
|