mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
Shift old policies UI into storage since its only being used there (#47497)
## Context This is one chonky boy of a PR, but it's just re-organizing files and folders to clean things up Storage Policies have been using the old Database policy UI (the one with the Dialog), so it makes most sense to shift those files under the `Storage` folder instead of keeping them under `Database`, so it's clearer which files are being consumed by whom, and easier to clean things up as well As part of this clean up, also tore out all the RLS generation logic from the Table Editor which are no longer used as they were affected by the change in files. Deprecated + deleted any unused code too <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Enhanced the policy editor with a centralized set of reusable templates, including general table templates plus predefined Realtime and queue access templates. * Updated the table creation flow so it no longer auto-generates additional RLS policy drafts. * **Bug Fixes** * Improved the policy table header badge layout for clearer RLS/API/lock indicators. * Simplified policy preview/save behavior so only meaningful edits are reflected in the applied SQL. * Streamlined the table-creation success messaging to remove conditional failure details. * **Tests** * Updated/removal of policy and table-creation test coverage to match the new behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
1 parent
804475fd3a
commit
bb4eaef0df
35 files changed
+567
-1424
No files matched your search
@@ -1,6 +1,341 @@
|
||||
export const POLICY_MODAL_VIEWS = {
|
||||
SELECTION: 'SELECTION',
|
||||
TEMPLATES: 'TEMPLATES',
|
||||
EDITOR: 'EDITOR',
|
||||
REVIEW: 'REVIEW',
|
||||
import { safeSql } from '@supabase/pg-meta/src/pg-format'
|
||||
import type { SafeSqlFragment } from '@supabase/pg-meta/src/pg-format'
|
||||
|
||||
export interface PolicyTemplate {
|
||||
id: string
|
||||
preview: boolean
|
||||
templateName: string
|
||||
description: string
|
||||
name: string
|
||||
statement: string
|
||||
definition: SafeSqlFragment
|
||||
check: SafeSqlFragment
|
||||
command: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL'
|
||||
roles: Array<string>
|
||||
}
|
||||
|
||||
/**
|
||||
* ----------------------------------------------------------------
|
||||
* PostgreSQL policy templates for the auth policies page
|
||||
* ----------------------------------------------------------------
|
||||
* id: Unique identifier for the monaco editor to dynamically refresh
|
||||
* templateName: As a display for a more descriptive title for the policy
|
||||
* description: Additional details about the template and how to make it yours
|
||||
* statement: SQL statement template for the policy
|
||||
*
|
||||
* name: Actual policy name that will be used in the editor
|
||||
* definition: Actual policy using expression that will be used in the editor
|
||||
* check: Actual policy with check expression that will be used in the editor
|
||||
* command: Operation to create policy for
|
||||
*/
|
||||
|
||||
export const getGeneralPolicyTemplates = (schema: string, table: string): PolicyTemplate[] => [
|
||||
{
|
||||
id: 'policy-1',
|
||||
preview: false,
|
||||
templateName: 'Enable read access to everyone',
|
||||
description:
|
||||
'This policy gives read access to your table for all users via the SELECT operation.',
|
||||
statement: `
|
||||
create policy "Enable read access for all users"
|
||||
on "${schema}"."${table}"
|
||||
for select using (true);`.trim(),
|
||||
name: 'Enable read access for all users',
|
||||
definition: safeSql`true`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-2',
|
||||
preview: false,
|
||||
templateName: 'Enable insert access for authenticated users only',
|
||||
description: 'This policy gives insert access to your table for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Enable insert for authenticated users only"
|
||||
on "${schema}"."${table}"
|
||||
for insert to authenticated
|
||||
with check (true);`.trim(),
|
||||
name: 'Enable insert for authenticated users only',
|
||||
definition: safeSql``,
|
||||
check: safeSql`true`,
|
||||
command: 'INSERT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-3',
|
||||
preview: false,
|
||||
templateName: 'Enable delete access for users based on their user ID *',
|
||||
description:
|
||||
'This policy assumes that your table has a column "user_id", and allows users to delete rows which the "user_id" column matches their ID',
|
||||
statement: `
|
||||
create policy "Enable delete for users based on user_id"
|
||||
on "${schema}"."${table}"
|
||||
for delete using (
|
||||
(select auth.uid()) = user_id
|
||||
);`.trim(),
|
||||
name: 'Enable delete for users based on user_id',
|
||||
definition: safeSql`(select auth.uid()) = user_id`,
|
||||
check: safeSql``,
|
||||
command: 'DELETE',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-4',
|
||||
preview: false,
|
||||
templateName: 'Enable insert access for users based on their user ID *',
|
||||
description:
|
||||
'This policy assumes that your table has a column "user_id", and allows users to insert rows which the "user_id" column matches their ID',
|
||||
statement: `
|
||||
create policy "Enable insert for users based on user_id"
|
||||
on "${schema}"."${table}"
|
||||
for insert with check (
|
||||
(select auth.uid()) = user_id
|
||||
);`.trim(),
|
||||
name: 'Enable insert for users based on user_id',
|
||||
definition: safeSql``,
|
||||
check: safeSql`(select auth.uid()) = user_id`,
|
||||
command: 'INSERT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-5',
|
||||
preview: true,
|
||||
name: 'Policy with table joins',
|
||||
templateName: 'Policy with table joins',
|
||||
description: `
|
||||
Query across tables to build more advanced RLS rules
|
||||
|
||||
Assuming 2 tables called \`teams\` and \`members\`, you can query both tables in the policy to control access to the members table.`,
|
||||
statement: `
|
||||
create policy "Members can update team details if they belong to the team"
|
||||
on teams for update using (
|
||||
(select auth.uid()) in (
|
||||
select user_id from members where team_id = id
|
||||
)
|
||||
);
|
||||
`.trim(),
|
||||
definition: safeSql`(select auth.uid()) in (select user_id from members where team_id = id)`,
|
||||
check: safeSql``,
|
||||
command: 'UPDATE',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-6',
|
||||
preview: true,
|
||||
templateName: 'Policy with security definer functions',
|
||||
description: `
|
||||
Useful in a many-to-many relationship where you want to restrict access to the linking table.
|
||||
|
||||
Assuming 2 tables called \`teams\` and \`members\`, you can use a security definer function in combination with a policy to control access to the members table.`.trim(),
|
||||
statement: `
|
||||
create or replace function get_teams_for_user(user_id uuid)
|
||||
returns setof bigint as $$
|
||||
select team_id from members where user_id = $1
|
||||
$$ stable language sql security definer;
|
||||
|
||||
create policy "Team members can update team members if they belong to the team"
|
||||
on members
|
||||
for all using (
|
||||
team_id in (select get_teams_for_user(auth.uid()))
|
||||
);
|
||||
`.trim(),
|
||||
name: 'Policy with security definer functions',
|
||||
definition: safeSql`team_id in (select get_teams_for_user(auth.uid()))`,
|
||||
check: safeSql``,
|
||||
command: 'ALL',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-7',
|
||||
preview: true,
|
||||
name: 'Policy to implement Time To Live (TTL)',
|
||||
templateName: 'Policy to implement Time To Live (TTL)',
|
||||
description: `
|
||||
Implement a TTL-like feature that you see in Instagram stories or Snapchat where messages expire after a day.
|
||||
|
||||
Rows under the table are available only if they have been created within the last 24 hours.`,
|
||||
statement: `
|
||||
create policy "Stories are live for a day"
|
||||
on "${schema}"."${table}"
|
||||
for select using (
|
||||
created_at > (current_timestamp - interval '1 day')
|
||||
);
|
||||
`.trim(),
|
||||
definition: safeSql`created_at > (current_timestamp - interval '1 day')`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-8',
|
||||
preview: false,
|
||||
templateName: 'Allow users to only view their own data',
|
||||
description: 'Restrict users to reading only their own data.',
|
||||
statement: `
|
||||
create policy "Enable users to view their own data only"
|
||||
on "${schema}"."${table}"
|
||||
for select
|
||||
to authenticated
|
||||
using (
|
||||
(select auth.uid()) = user_id
|
||||
);`.trim(),
|
||||
name: 'Enable users to view their own data only',
|
||||
definition: safeSql`(select auth.uid()) = user_id`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
]
|
||||
|
||||
export const getRealtimePolicyTemplates = (): PolicyTemplate[] => {
|
||||
const results = [
|
||||
{
|
||||
id: 'policy-broadcast-1',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for broadcasts for authenticated users only',
|
||||
description: 'This policy allows listening for broadcasts for authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow listening for broadcasts for authenticated users only"
|
||||
on realtime.messages for select
|
||||
to authenticated
|
||||
using ( realtime.messages.extension = 'broadcast' );`.trim(),
|
||||
name: 'Allow listening for broadcasts for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-2',
|
||||
preview: false,
|
||||
templateName: 'Allow pushing broadcasts for authenticated users only',
|
||||
description: 'This policy allows pushing broadcasts for authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow pushing broadcasts for authenticated users only"
|
||||
ON realtime.messages for insert
|
||||
TO authenticated
|
||||
with check ( realtime.messages.extension = 'broadcast' );`.trim(),
|
||||
name: 'Allow pushing broadcasts for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast'`,
|
||||
check: safeSql`realtime.messages.extension = 'broadcast'`,
|
||||
command: 'INSERT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-3',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for broadcasts from a specific channel',
|
||||
description: 'This policy allows listening for broadcasts from a specific channel.',
|
||||
statement: `
|
||||
create policy "Allow listening for broadcasts from a specific channel"
|
||||
on realtime.messages for select
|
||||
using ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow listening for broadcasts from a specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-4',
|
||||
preview: false,
|
||||
templateName: 'Allow pushing broadcasts to specific channel',
|
||||
description: 'This policy allow pushing broadcasts to specific channel.',
|
||||
statement: `
|
||||
create policy "Allow pushing broadcasts to specific channel"
|
||||
ON realtime.messages for insert
|
||||
with check ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow pushing broadcasts to specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
command: 'INSERT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-1',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for presences on all channels for authenticated users only',
|
||||
description:
|
||||
'This policy enables listening for presences on all channels for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow listening for presences on all channels for authenticated users only"
|
||||
on realtime.messages for select
|
||||
to authenticated
|
||||
using ( realtime.messages.extension = 'presence' );`.trim(),
|
||||
name: 'Allow listening for presences on all channels for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'presence'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-2',
|
||||
preview: false,
|
||||
templateName: 'Allow broadcasting presences on all channels for authenticated users only',
|
||||
description:
|
||||
'This policy enables broadcasting presences on all channels for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow broadcasting presences on all channels for authenticated users only"
|
||||
ON realtime.messages for insert
|
||||
TO authenticated
|
||||
with check ( realtime.messages.extension = 'presence' );
|
||||
;`.trim(),
|
||||
name: 'Allow broadcasting presences on all channels for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'presence'`,
|
||||
check: safeSql`realtime.messages.extension = 'presence'`,
|
||||
command: 'INSERT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-3',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for presences from a specific channel',
|
||||
description: 'This policy enables listening for presences from a specific channel.',
|
||||
statement: `
|
||||
create policy "Allow listening for presences from a specific channel"
|
||||
on realtime.messages for select
|
||||
using ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow listening for presences from a specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-4',
|
||||
preview: false,
|
||||
templateName: 'Publish presence to a specific channel',
|
||||
description: 'This policy allows publishing presence to a specific channel.',
|
||||
statement: `
|
||||
create policy "Publish presence to a specific channel"
|
||||
ON realtime.messages for insert
|
||||
with check ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );
|
||||
;`.trim(),
|
||||
name: 'Publish presence to a specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
command: 'INSERT',
|
||||
roles: [],
|
||||
},
|
||||
] as PolicyTemplate[]
|
||||
return results
|
||||
}
|
||||
|
||||
export const getQueuePolicyTemplates = (): PolicyTemplate[] => {
|
||||
return [
|
||||
{
|
||||
id: 'policy-queues-1',
|
||||
preview: false,
|
||||
templateName: 'Allow access to queue',
|
||||
statement: ``.trim(),
|
||||
name: 'Allow anon and authenticated to access messages from queue',
|
||||
description:
|
||||
'Base policy to ensure that anon and authenticated can only access appropriate rows. USING and CHECK statements will need to be adjusted accordingly',
|
||||
definition: safeSql`true`,
|
||||
check: safeSql`true`,
|
||||
command: 'ALL',
|
||||
roles: ['anon', 'authenticated'],
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
import { safeSql } from '@supabase/pg-meta'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
generateAiPoliciesForTable,
|
||||
generateProgrammaticPoliciesForTable,
|
||||
generateStartingPoliciesForTable,
|
||||
type GeneratedPolicy,
|
||||
} from './Policies.utils'
|
||||
import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query'
|
||||
|
||||
// Mock generateSqlPolicy for AI tests
|
||||
const mockGenerateSqlPolicy = vi.fn()
|
||||
vi.mock('@/data/ai/sql-policy-mutation', () => ({
|
||||
generateSqlPolicy: (...args: unknown[]) => mockGenerateSqlPolicy(...args),
|
||||
}))
|
||||
|
||||
// Helper to create a foreign key constraint
|
||||
const createForeignKey = (overrides: Partial<ForeignKeyConstraint> = {}): ForeignKeyConstraint => ({
|
||||
id: 1,
|
||||
constraint_name: 'fk_constraint',
|
||||
source_id: 100,
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['user_id'],
|
||||
target_id: 200,
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
deletion_action: 'NO ACTION',
|
||||
update_action: 'NO ACTION',
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('Policies.utils - Policy Generation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('generateProgrammaticPoliciesForTable', () => {
|
||||
it('should generate 4 CRUD policies for direct FK to auth.users', () => {
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['user_id'],
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
expect(policies).toHaveLength(4)
|
||||
|
||||
const commands = policies.map((p) => p.command)
|
||||
expect(commands).toContain('SELECT')
|
||||
expect(commands).toContain('INSERT')
|
||||
expect(commands).toContain('UPDATE')
|
||||
expect(commands).toContain('DELETE')
|
||||
})
|
||||
|
||||
it('should return empty array when no FK path to auth.users exists', () => {
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['category_id'],
|
||||
target_schema: 'public',
|
||||
target_table: 'categories',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
expect(policies).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should return empty array when foreignKeyConstraints is empty', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints: [],
|
||||
})
|
||||
|
||||
expect(policies).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should generate policies with EXISTS clause for indirect FK path (2 hops)', () => {
|
||||
// posts -> profiles -> auth.users
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
id: 1,
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['profile_id'],
|
||||
target_schema: 'public',
|
||||
target_table: 'profiles',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
createForeignKey({
|
||||
id: 2,
|
||||
source_schema: 'public',
|
||||
source_table: 'profiles',
|
||||
source_columns: ['user_id'],
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
expect(policies).toHaveLength(4)
|
||||
|
||||
// Check that the expression contains EXISTS for indirect path
|
||||
const selectPolicy = policies.find((p) => p.command === 'SELECT')
|
||||
expect(selectPolicy?.definition).toContain('exists')
|
||||
expect(selectPolicy?.sql).toContain('exists')
|
||||
})
|
||||
|
||||
describe('policy structure validation', () => {
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['user_id'],
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
it('should include all required fields in generated policies', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
for (const policy of policies) {
|
||||
expect(policy).toHaveProperty('name')
|
||||
expect(policy).toHaveProperty('sql')
|
||||
expect(policy).toHaveProperty('command')
|
||||
expect(policy).toHaveProperty('table', 'posts')
|
||||
expect(policy).toHaveProperty('schema', 'public')
|
||||
expect(policy).toHaveProperty('action', 'PERMISSIVE')
|
||||
expect(policy).toHaveProperty('roles')
|
||||
expect(policy.roles).toContain('authenticated')
|
||||
}
|
||||
})
|
||||
|
||||
it('SELECT policy should have definition but no check', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
const selectPolicy = policies.find((p) => p.command === 'SELECT')
|
||||
expect(selectPolicy?.definition).toBeDefined()
|
||||
expect(selectPolicy?.check).toBeUndefined()
|
||||
})
|
||||
|
||||
it('DELETE policy should have definition but no check', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
const deletePolicy = policies.find((p) => p.command === 'DELETE')
|
||||
expect(deletePolicy?.definition).toBeDefined()
|
||||
expect(deletePolicy?.check).toBeUndefined()
|
||||
})
|
||||
|
||||
it('INSERT policy should have check but no definition', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
const insertPolicy = policies.find((p) => p.command === 'INSERT')
|
||||
expect(insertPolicy?.definition).toBeUndefined()
|
||||
expect(insertPolicy?.check).toBeDefined()
|
||||
})
|
||||
|
||||
it('UPDATE policy should have both definition and check', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
const updatePolicy = policies.find((p) => p.command === 'UPDATE')
|
||||
expect(updatePolicy?.definition).toBeDefined()
|
||||
expect(updatePolicy?.check).toBeDefined()
|
||||
})
|
||||
|
||||
it('should generate correct SQL syntax for direct FK', () => {
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
const selectPolicy = policies.find((p) => p.command === 'SELECT')
|
||||
expect(selectPolicy?.sql).toContain('CREATE POLICY')
|
||||
expect(selectPolicy?.sql).toContain('public.posts')
|
||||
expect(selectPolicy?.sql).toContain('AS PERMISSIVE FOR SELECT')
|
||||
expect(selectPolicy?.sql).toContain('TO authenticated')
|
||||
expect(selectPolicy?.sql).toContain('USING')
|
||||
expect(selectPolicy?.sql).toContain('auth.uid()')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle non-public schema', () => {
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
source_schema: 'private',
|
||||
source_table: 'documents',
|
||||
source_columns: ['owner_id'],
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
const policies = generateProgrammaticPoliciesForTable({
|
||||
table: { name: 'documents', schema: 'private' },
|
||||
foreignKeyConstraints,
|
||||
})
|
||||
|
||||
expect(policies).toHaveLength(4)
|
||||
expect(policies[0].schema).toBe('private')
|
||||
expect(policies[0].sql).toContain('private.documents')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateAiPoliciesForTable', () => {
|
||||
const mockAiPolicies: GeneratedPolicy[] = [
|
||||
{
|
||||
name: 'ai_select_policy',
|
||||
sql: 'CREATE POLICY "ai_select_policy" ON public.posts FOR SELECT USING (true);',
|
||||
command: 'SELECT',
|
||||
table: 'posts',
|
||||
schema: 'public',
|
||||
definition: safeSql`true`,
|
||||
action: 'PERMISSIVE',
|
||||
roles: ['public'],
|
||||
},
|
||||
]
|
||||
|
||||
it('should return policies from AI when called with valid inputs', async () => {
|
||||
mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
|
||||
|
||||
const policies = await generateAiPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
columns: [{ name: 'id' }, { name: 'title' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
})
|
||||
|
||||
expect(mockGenerateSqlPolicy).toHaveBeenCalledWith({
|
||||
tableName: 'posts',
|
||||
schema: 'public',
|
||||
columns: ['id', 'title'],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
})
|
||||
expect(policies).toEqual(mockAiPolicies)
|
||||
})
|
||||
|
||||
it('should return empty array when connectionString is null', async () => {
|
||||
const policies = await generateAiPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: null,
|
||||
})
|
||||
|
||||
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
|
||||
expect(policies).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array when connectionString is undefined', async () => {
|
||||
const policies = await generateAiPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: undefined,
|
||||
})
|
||||
|
||||
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
|
||||
expect(policies).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle API errors gracefully and return empty array', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
mockGenerateSqlPolicy.mockRejectedValue(new Error('API error'))
|
||||
|
||||
const policies = await generateAiPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
})
|
||||
|
||||
expect(policies).toEqual([])
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith('AI policy generation failed:', expect.any(Error))
|
||||
|
||||
consoleLogSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should trim column names before sending to API', async () => {
|
||||
mockGenerateSqlPolicy.mockResolvedValue([])
|
||||
|
||||
await generateAiPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
columns: [{ name: ' id ' }, { name: ' title ' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
})
|
||||
|
||||
expect(mockGenerateSqlPolicy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
columns: ['id', 'title'],
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateStartingPoliciesForTable', () => {
|
||||
const mockAiPolicies: GeneratedPolicy[] = [
|
||||
{
|
||||
name: 'ai_policy',
|
||||
sql: 'CREATE POLICY "ai_policy" ON public.posts FOR SELECT USING (true);',
|
||||
command: 'SELECT',
|
||||
table: 'posts',
|
||||
schema: 'public',
|
||||
definition: safeSql`true`,
|
||||
action: 'PERMISSIVE',
|
||||
roles: ['public'],
|
||||
},
|
||||
]
|
||||
|
||||
it('should use programmatic policies when FK path exists (does not call AI)', async () => {
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['user_id'],
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
const policies = await generateStartingPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
enableAi: true,
|
||||
})
|
||||
|
||||
expect(policies).toHaveLength(4)
|
||||
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fall back to AI when no FK path exists and enableAi is true', async () => {
|
||||
mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
|
||||
|
||||
const policies = await generateStartingPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints: [],
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
enableAi: true,
|
||||
})
|
||||
|
||||
expect(mockGenerateSqlPolicy).toHaveBeenCalled()
|
||||
expect(policies).toEqual(mockAiPolicies)
|
||||
})
|
||||
|
||||
it('should return empty array when no FK path exists and enableAi is false', async () => {
|
||||
const policies = await generateStartingPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints: [],
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
enableAi: false,
|
||||
})
|
||||
|
||||
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
|
||||
expect(policies).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array when no FK path and AI returns empty', async () => {
|
||||
mockGenerateSqlPolicy.mockResolvedValue([])
|
||||
|
||||
const policies = await generateStartingPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints: [],
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
enableAi: true,
|
||||
})
|
||||
|
||||
expect(policies).toEqual([])
|
||||
})
|
||||
|
||||
it('should prioritize programmatic over AI even when both could generate policies', async () => {
|
||||
mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies)
|
||||
|
||||
const foreignKeyConstraints: ForeignKeyConstraint[] = [
|
||||
createForeignKey({
|
||||
source_schema: 'public',
|
||||
source_table: 'posts',
|
||||
source_columns: ['user_id'],
|
||||
target_schema: 'auth',
|
||||
target_table: 'users',
|
||||
target_columns: ['id'],
|
||||
}),
|
||||
]
|
||||
|
||||
const policies = await generateStartingPoliciesForTable({
|
||||
table: { name: 'posts', schema: 'public' },
|
||||
foreignKeyConstraints,
|
||||
columns: [{ name: 'id' }],
|
||||
projectRef: 'test-project',
|
||||
connectionString: 'postgresql://localhost:5432/test',
|
||||
enableAi: true,
|
||||
})
|
||||
|
||||
// Should return 4 programmatic policies, not 1 AI policy
|
||||
expect(policies).toHaveLength(4)
|
||||
expect(mockGenerateSqlPolicy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,477 +0,0 @@
|
||||
import {
|
||||
acceptUntrustedSql,
|
||||
ident,
|
||||
safeSql,
|
||||
untrustedSql,
|
||||
type DisplayableSqlFragment,
|
||||
type SafeSqlFragment,
|
||||
} from '@supabase/pg-meta'
|
||||
import type { PGPolicy } from '@supabase/pg-meta'
|
||||
import { has, isEmpty, isEqual } from 'lodash'
|
||||
|
||||
import {
|
||||
DraftPostgresPolicyCreatePayload,
|
||||
DraftPostgresPolicyUpdatePayload,
|
||||
PolicyFormField,
|
||||
PolicyForReview,
|
||||
} 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 }
|
||||
}
|
||||
|
||||
// These constructors return DRAFT payloads — `definition`/`check` are still
|
||||
// `DisplayableSqlFragment`. Promotion to `SafeSqlFragment` must happen at the user gesture
|
||||
// (the Save click in `PolicyEditorModal`), not here, since this module has no guarantee that
|
||||
// it was reached via a deliberate user action.
|
||||
export const createPayloadForCreatePolicy = (
|
||||
policyFormFields: PolicyFormField
|
||||
): DraftPostgresPolicyCreatePayload => {
|
||||
const { name, schema, table, command, definition, check, roles } = policyFormFields
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
table,
|
||||
action: 'PERMISSIVE',
|
||||
command: command || undefined,
|
||||
definition: !definition ? undefined : untrustedSql(definition),
|
||||
check: !check ? undefined : untrustedSql(check),
|
||||
roles: roles.length > 0 ? roles : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const createPayloadForUpdatePolicy = (
|
||||
policyFormFields: PolicyFormField,
|
||||
originalPolicyFormFields: PGPolicy
|
||||
): DraftPostgresPolicyUpdatePayload => {
|
||||
const { definition, check } = policyFormFields
|
||||
const formattedDefinition = definition ? definition.replace(/\s+/g, ' ').trim() : definition
|
||||
const formattedCheck = check ? check.replace(/\s+/g, ' ').trim() : check
|
||||
|
||||
const payload: DraftPostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id }
|
||||
|
||||
if (!isEqual(policyFormFields.name, originalPolicyFormFields.name)) {
|
||||
payload.name = policyFormFields.name
|
||||
}
|
||||
if (!isEqual(formattedDefinition, originalPolicyFormFields.definition)) {
|
||||
payload.definition = !formattedDefinition ? undefined : untrustedSql(formattedDefinition)
|
||||
}
|
||||
if (!isEqual(formattedCheck, originalPolicyFormFields.check)) {
|
||||
payload.check = !formattedCheck ? undefined : untrustedSql(formattedCheck)
|
||||
}
|
||||
if (!isEqual(policyFormFields.roles, originalPolicyFormFields.roles)) {
|
||||
if (policyFormFields.roles.length === 0) payload.roles = ['public']
|
||||
else payload.roles = policyFormFields.roles || undefined
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
// --- Policy Generation ---
|
||||
|
||||
/**
|
||||
* A policy generated for display/staging in the table editor.
|
||||
* `definition`/`check` are `DisplayableSqlFragment` because generators have different provenance:
|
||||
* programmatic generation produces `SafeSqlFragment` (composed via `safeSql`), AI generation
|
||||
* produces `UntrustedSqlFragment` (third-party output). Consumers must promote via
|
||||
* `acceptUntrustedSql` at a user gesture before executing.
|
||||
*/
|
||||
export type GeneratedPolicy = Required<
|
||||
Pick<CreatePolicyBody, 'name' | 'table' | 'schema' | 'action' | 'roles'>
|
||||
> &
|
||||
Pick<CreatePolicyBody, 'command'> & {
|
||||
definition?: DisplayableSqlFragment
|
||||
check?: DisplayableSqlFragment
|
||||
sql: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link GeneratedPolicy} whose `definition`/`check` have already been promoted to
|
||||
* `SafeSqlFragment`. Producing one of these is the contract that says: the user gesture
|
||||
* required to execute this SQL has already happened.
|
||||
*/
|
||||
export type AcceptedGeneratedPolicy = Omit<GeneratedPolicy, 'definition' | 'check'> & {
|
||||
definition?: SafeSqlFragment
|
||||
check?: SafeSqlFragment
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes a {@link GeneratedPolicy} to an {@link AcceptedGeneratedPolicy}.
|
||||
* ONLY call from an event handler tied to a deliberate user action (e.g. the Save click
|
||||
* on the table editor). Never call from useEffect, render, or any path that runs without
|
||||
* a user gesture.
|
||||
*/
|
||||
export const acceptGeneratedPolicy = (policy: GeneratedPolicy): AcceptedGeneratedPolicy => ({
|
||||
...policy,
|
||||
definition: policy.definition === undefined ? undefined : acceptUntrustedSql(policy.definition),
|
||||
check: policy.check === undefined ? undefined : acceptUntrustedSql(policy.check),
|
||||
})
|
||||
|
||||
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[]): SafeSqlFragment => {
|
||||
if (path.length === 0) return safeSql``
|
||||
|
||||
// Direct FK to auth.users
|
||||
if (path.length === 1) {
|
||||
return safeSql`(select auth.uid()) = ${ident(path[0].source_column_name)}`
|
||||
}
|
||||
|
||||
// Indirect path - build EXISTS with JOINs
|
||||
const [first, ...rest] = path
|
||||
const firstTarget = safeSql`${ident(first.target_table_schema)}.${ident(first.target_table_name)}`
|
||||
const source = safeSql`${ident(first.source_schema)}.${ident(first.source_table_name)}`
|
||||
const last = path[path.length - 1]
|
||||
|
||||
const joins = rest.slice(0, -1).reduce<SafeSqlFragment>(
|
||||
(acc, 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)
|
||||
const join = safeSql`join ${targetSchema}.${targetTable} on ${targetSchema}.${targetTable}.${targetColumn} = ${sourceSchema}.${sourceTable}.${sourceColumn}`
|
||||
return acc.length === 0 ? join : safeSql`${acc}\n ${join}`
|
||||
},
|
||||
safeSql``
|
||||
)
|
||||
|
||||
return safeSql`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 {
|
||||
return await generateSqlPolicy({
|
||||
tableName: table.name,
|
||||
schema: table.schema,
|
||||
columns: columns.map((col) => col.name.trim()),
|
||||
projectRef,
|
||||
connectionString: connectionString ?? '',
|
||||
})
|
||||
} 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 []
|
||||
}
|
||||
-329
@@ -1,329 +0,0 @@
|
||||
import { safeSql } from '@supabase/pg-meta/src/pg-format'
|
||||
|
||||
import { PolicyTemplate } from '../PolicyTemplates/PolicyTemplates.constants'
|
||||
|
||||
/**
|
||||
* ----------------------------------------------------------------
|
||||
* PostgreSQL policy templates for the auth policies page
|
||||
* ----------------------------------------------------------------
|
||||
* id: Unique identifier for the monaco editor to dynamically refresh
|
||||
* templateName: As a display for a more descriptive title for the policy
|
||||
* description: Additional details about the template and how to make it yours
|
||||
* statement: SQL statement template for the policy
|
||||
*
|
||||
* name: Actual policy name that will be used in the editor
|
||||
* definition: Actual policy using expression that will be used in the editor
|
||||
* check: Actual policy with check expression that will be used in the editor
|
||||
* command: Operation to create policy for
|
||||
*/
|
||||
|
||||
export const getGeneralPolicyTemplates = (schema: string, table: string): PolicyTemplate[] => [
|
||||
{
|
||||
id: 'policy-1',
|
||||
preview: false,
|
||||
templateName: 'Enable read access to everyone',
|
||||
description:
|
||||
'This policy gives read access to your table for all users via the SELECT operation.',
|
||||
statement: `
|
||||
create policy "Enable read access for all users"
|
||||
on "${schema}"."${table}"
|
||||
for select using (true);`.trim(),
|
||||
name: 'Enable read access for all users',
|
||||
definition: safeSql`true`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-2',
|
||||
preview: false,
|
||||
templateName: 'Enable insert access for authenticated users only',
|
||||
description: 'This policy gives insert access to your table for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Enable insert for authenticated users only"
|
||||
on "${schema}"."${table}"
|
||||
for insert to authenticated
|
||||
with check (true);`.trim(),
|
||||
name: 'Enable insert for authenticated users only',
|
||||
definition: safeSql``,
|
||||
check: safeSql`true`,
|
||||
command: 'INSERT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-3',
|
||||
preview: false,
|
||||
templateName: 'Enable delete access for users based on their user ID *',
|
||||
description:
|
||||
'This policy assumes that your table has a column "user_id", and allows users to delete rows which the "user_id" column matches their ID',
|
||||
statement: `
|
||||
create policy "Enable delete for users based on user_id"
|
||||
on "${schema}"."${table}"
|
||||
for delete using (
|
||||
(select auth.uid()) = user_id
|
||||
);`.trim(),
|
||||
name: 'Enable delete for users based on user_id',
|
||||
definition: safeSql`(select auth.uid()) = user_id`,
|
||||
check: safeSql``,
|
||||
command: 'DELETE',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-4',
|
||||
preview: false,
|
||||
templateName: 'Enable insert access for users based on their user ID *',
|
||||
description:
|
||||
'This policy assumes that your table has a column "user_id", and allows users to insert rows which the "user_id" column matches their ID',
|
||||
statement: `
|
||||
create policy "Enable insert for users based on user_id"
|
||||
on "${schema}"."${table}"
|
||||
for insert with check (
|
||||
(select auth.uid()) = user_id
|
||||
);`.trim(),
|
||||
name: 'Enable insert for users based on user_id',
|
||||
definition: safeSql``,
|
||||
check: safeSql`(select auth.uid()) = user_id`,
|
||||
command: 'INSERT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-5',
|
||||
preview: true,
|
||||
name: 'Policy with table joins',
|
||||
templateName: 'Policy with table joins',
|
||||
description: `
|
||||
Query across tables to build more advanced RLS rules
|
||||
|
||||
Assuming 2 tables called \`teams\` and \`members\`, you can query both tables in the policy to control access to the members table.`,
|
||||
statement: `
|
||||
create policy "Members can update team details if they belong to the team"
|
||||
on teams for update using (
|
||||
(select auth.uid()) in (
|
||||
select user_id from members where team_id = id
|
||||
)
|
||||
);
|
||||
`.trim(),
|
||||
definition: safeSql`(select auth.uid()) in (select user_id from members where team_id = id)`,
|
||||
check: safeSql``,
|
||||
command: 'UPDATE',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-6',
|
||||
preview: true,
|
||||
templateName: 'Policy with security definer functions',
|
||||
description: `
|
||||
Useful in a many-to-many relationship where you want to restrict access to the linking table.
|
||||
|
||||
Assuming 2 tables called \`teams\` and \`members\`, you can use a security definer function in combination with a policy to control access to the members table.`.trim(),
|
||||
statement: `
|
||||
create or replace function get_teams_for_user(user_id uuid)
|
||||
returns setof bigint as $$
|
||||
select team_id from members where user_id = $1
|
||||
$$ stable language sql security definer;
|
||||
|
||||
create policy "Team members can update team members if they belong to the team"
|
||||
on members
|
||||
for all using (
|
||||
team_id in (select get_teams_for_user(auth.uid()))
|
||||
);
|
||||
`.trim(),
|
||||
name: 'Policy with security definer functions',
|
||||
definition: safeSql`team_id in (select get_teams_for_user(auth.uid()))`,
|
||||
check: safeSql``,
|
||||
command: 'ALL',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-7',
|
||||
preview: true,
|
||||
name: 'Policy to implement Time To Live (TTL)',
|
||||
templateName: 'Policy to implement Time To Live (TTL)',
|
||||
description: `
|
||||
Implement a TTL-like feature that you see in Instagram stories or Snapchat where messages expire after a day.
|
||||
|
||||
Rows under the table are available only if they have been created within the last 24 hours.`,
|
||||
statement: `
|
||||
create policy "Stories are live for a day"
|
||||
on "${schema}"."${table}"
|
||||
for select using (
|
||||
created_at > (current_timestamp - interval '1 day')
|
||||
);
|
||||
`.trim(),
|
||||
definition: safeSql`created_at > (current_timestamp - interval '1 day')`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-8',
|
||||
preview: false,
|
||||
templateName: 'Allow users to only view their own data',
|
||||
description: 'Restrict users to reading only their own data.',
|
||||
statement: `
|
||||
create policy "Enable users to view their own data only"
|
||||
on "${schema}"."${table}"
|
||||
for select
|
||||
to authenticated
|
||||
using (
|
||||
(select auth.uid()) = user_id
|
||||
);`.trim(),
|
||||
name: 'Enable users to view their own data only',
|
||||
definition: safeSql`(select auth.uid()) = user_id`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
]
|
||||
|
||||
export const getRealtimePolicyTemplates = (): PolicyTemplate[] => {
|
||||
const results = [
|
||||
{
|
||||
id: 'policy-broadcast-1',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for broadcasts for authenticated users only',
|
||||
description: 'This policy allows listening for broadcasts for authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow listening for broadcasts for authenticated users only"
|
||||
on realtime.messages for select
|
||||
to authenticated
|
||||
using ( realtime.messages.extension = 'broadcast' );`.trim(),
|
||||
name: 'Allow listening for broadcasts for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-2',
|
||||
preview: false,
|
||||
templateName: 'Allow pushing broadcasts for authenticated users only',
|
||||
description: 'This policy allows pushing broadcasts for authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow pushing broadcasts for authenticated users only"
|
||||
ON realtime.messages for insert
|
||||
TO authenticated
|
||||
with check ( realtime.messages.extension = 'broadcast' );`.trim(),
|
||||
name: 'Allow pushing broadcasts for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast'`,
|
||||
check: safeSql`realtime.messages.extension = 'broadcast'`,
|
||||
command: 'INSERT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-3',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for broadcasts from a specific channel',
|
||||
description: 'This policy allows listening for broadcasts from a specific channel.',
|
||||
statement: `
|
||||
create policy "Allow listening for broadcasts from a specific channel"
|
||||
on realtime.messages for select
|
||||
using ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow listening for broadcasts from a specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-broadcast-4',
|
||||
preview: false,
|
||||
templateName: 'Allow pushing broadcasts to specific channel',
|
||||
description: 'This policy allow pushing broadcasts to specific channel.',
|
||||
statement: `
|
||||
create policy "Allow pushing broadcasts to specific channel"
|
||||
ON realtime.messages for insert
|
||||
with check ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow pushing broadcasts to specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
|
||||
command: 'INSERT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-1',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for presences on all channels for authenticated users only',
|
||||
description:
|
||||
'This policy enables listening for presences on all channels for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow listening for presences on all channels for authenticated users only"
|
||||
on realtime.messages for select
|
||||
to authenticated
|
||||
using ( realtime.messages.extension = 'presence' );`.trim(),
|
||||
name: 'Allow listening for presences on all channels for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'presence'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-2',
|
||||
preview: false,
|
||||
templateName: 'Allow broadcasting presences on all channels for authenticated users only',
|
||||
description:
|
||||
'This policy enables broadcasting presences on all channels for all authenticated users only.',
|
||||
statement: `
|
||||
create policy "Allow broadcasting presences on all channels for authenticated users only"
|
||||
ON realtime.messages for insert
|
||||
TO authenticated
|
||||
with check ( realtime.messages.extension = 'presence' );
|
||||
;`.trim(),
|
||||
name: 'Allow broadcasting presences on all channels for authenticated users only',
|
||||
definition: safeSql`realtime.messages.extension = 'presence'`,
|
||||
check: safeSql`realtime.messages.extension = 'presence'`,
|
||||
command: 'INSERT',
|
||||
roles: ['authenticated'],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-3',
|
||||
preview: false,
|
||||
templateName: 'Allow listening for presences from a specific channel',
|
||||
description: 'This policy enables listening for presences from a specific channel.',
|
||||
statement: `
|
||||
create policy "Allow listening for presences from a specific channel"
|
||||
on realtime.messages for select
|
||||
using ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );`.trim(),
|
||||
name: 'Allow listening for presences from a specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql``,
|
||||
command: 'SELECT',
|
||||
roles: [],
|
||||
},
|
||||
{
|
||||
id: 'policy-presences-4',
|
||||
preview: false,
|
||||
templateName: 'Publish presence to a specific channel',
|
||||
description: 'This policy allows publishing presence to a specific channel.',
|
||||
statement: `
|
||||
create policy "Publish presence to a specific channel"
|
||||
ON realtime.messages for insert
|
||||
with check ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );
|
||||
;`.trim(),
|
||||
name: 'Publish presence to a specific channel',
|
||||
definition: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
check: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
|
||||
command: 'INSERT',
|
||||
roles: [],
|
||||
},
|
||||
] as PolicyTemplate[]
|
||||
return results
|
||||
}
|
||||
|
||||
export const getQueuePolicyTemplates = (): PolicyTemplate[] => {
|
||||
return [
|
||||
{
|
||||
id: 'policy-queues-1',
|
||||
preview: false,
|
||||
templateName: 'Allow access to queue',
|
||||
statement: ``.trim(),
|
||||
name: 'Allow anon and authenticated to access messages from queue',
|
||||
description:
|
||||
'Base policy to ensure that anon and authenticated can only access appropriate rows. USING and CHECK statements will need to be adjusted accordingly',
|
||||
definition: safeSql`true`,
|
||||
check: safeSql`true`,
|
||||
command: 'ALL',
|
||||
roles: ['anon', 'authenticated'],
|
||||
},
|
||||
]
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ import {
|
||||
getGeneralPolicyTemplates,
|
||||
getQueuePolicyTemplates,
|
||||
getRealtimePolicyTemplates,
|
||||
} from '../PolicyEditorModal/PolicyEditorModal.constants'
|
||||
} from '../Policies.constants'
|
||||
import { Markdown } from '@/components/interfaces/Markdown'
|
||||
import CardButton from '@/components/ui/CardButton'
|
||||
import CopyButton from '@/components/ui/CopyButton'
|
||||
|
||||
+12
-10
@@ -56,24 +56,26 @@ export const PolicyTableRowHeader = ({
|
||||
>
|
||||
<Table strokeWidth={1.5} size={16} className="text-foreground-muted" />
|
||||
<CardTitle className="m-0 normal-case">{table.name}</CardTitle>
|
||||
</EditorTablePageLink>
|
||||
<div className="flex items-center gap-x-1">
|
||||
{!table.rls_enabled && (
|
||||
<Badge variant="warning" className="shrink-0">
|
||||
<Badge variant="warning" className="h-5">
|
||||
RLS Disabled
|
||||
</Badge>
|
||||
)}
|
||||
{!isLoadingApiAccess && !hasApiAccess && (
|
||||
<Badge variant="default" className="shrink-0">
|
||||
<Badge variant="default" className="h-5">
|
||||
API Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</EditorTablePageLink>
|
||||
{isTableLocked && (
|
||||
<Badge>
|
||||
<span className="flex gap-2 items-center text-xs uppercase text-foreground-lighter">
|
||||
<Lock size={12} /> Locked
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
{isTableLocked && (
|
||||
<Badge className="h-5">
|
||||
<span className="flex gap-x-1 items-center text-foreground-lighter">
|
||||
<Lock size={10} /> Locked
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isTableLocked && (
|
||||
<div className="flex-1">
|
||||
|
||||
@@ -94,7 +94,9 @@ const PolicyTableRowComponent = ({
|
||||
|
||||
return (
|
||||
<Card className={cn(isPubliclyReadable && 'border-warning-500')}>
|
||||
<CardHeader className={cn('py-3 px-4', status !== 'secured' && 'border-b-0')}>
|
||||
<CardHeader
|
||||
className={cn('py-3 px-4', status !== 'secured' && status !== 'unknown' && 'border-b-0')}
|
||||
>
|
||||
<PolicyTableRowHeader
|
||||
table={table}
|
||||
isLocked={isLocked}
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
import type { SafeSqlFragment } from '@supabase/pg-meta/src/pg-format'
|
||||
|
||||
export interface PolicyTemplate {
|
||||
id: string
|
||||
preview: boolean
|
||||
templateName: string
|
||||
description: string
|
||||
name: string
|
||||
statement: string
|
||||
definition: SafeSqlFragment
|
||||
check: SafeSqlFragment
|
||||
command: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL'
|
||||
roles: Array<string>
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from './Storage.constants'
|
||||
import type { PolicyFormField } from '@/components/interfaces/Database/Policies/Policies.types'
|
||||
import { PolicyFormField } from './StoragePolicies/StoragePolicies.types'
|
||||
|
||||
export interface StoragePolicyFormField extends PolicyFormField {
|
||||
allowedOperations: string[]
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+6
@@ -0,0 +1,6 @@
|
||||
export const POLICY_MODAL_VIEWS = {
|
||||
SELECTION: 'SELECTION',
|
||||
TEMPLATES: 'TEMPLATES',
|
||||
EDITOR: 'EDITOR',
|
||||
REVIEW: 'REVIEW',
|
||||
}
|
||||
+2
-4
@@ -2,7 +2,7 @@ import { noop } from 'lodash'
|
||||
import { ChevronLeft, FlaskConical } from 'lucide-react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
import { POLICY_MODAL_VIEWS } from '../Policies.constants'
|
||||
import { POLICY_MODAL_VIEWS } from './PolicyEditorModal.constants'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
@@ -16,7 +16,7 @@ interface PolicyEditorModalTitleProps {
|
||||
onToggleFeaturePreviewModal: () => void
|
||||
}
|
||||
|
||||
const PolicyEditorModalTitle = ({
|
||||
export const PolicyEditorModalTitle = ({
|
||||
view,
|
||||
schema,
|
||||
table,
|
||||
@@ -65,5 +65,3 @@ const PolicyEditorModalTitle = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PolicyEditorModalTitle
|
||||
+12
-10
@@ -4,7 +4,10 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from 'ui'
|
||||
|
||||
import { POLICY_MODAL_VIEWS } from '../Policies.constants'
|
||||
import { PolicyEditor } from '../PolicyEditor'
|
||||
import { PolicyReview } from '../PolicyReview'
|
||||
import { PolicySelection } from '../PolicySelection'
|
||||
import { PolicyTemplates } from '../PolicyTemplates'
|
||||
import {
|
||||
DraftPostgresPolicyCreatePayload,
|
||||
DraftPostgresPolicyUpdatePayload,
|
||||
@@ -12,20 +15,19 @@ import {
|
||||
PolicyForReview,
|
||||
PostgresPolicyCreatePayload,
|
||||
PostgresPolicyUpdatePayload,
|
||||
} from '../Policies.types'
|
||||
} from '../StoragePolicies.types'
|
||||
import {
|
||||
createPayloadForCreatePolicy,
|
||||
createPayloadForUpdatePolicy,
|
||||
createSQLPolicy,
|
||||
} from '../Policies.utils'
|
||||
import { PolicyEditor } from '../PolicyEditor'
|
||||
import { PolicyReview } from '../PolicyReview'
|
||||
import PolicySelection from '../PolicySelection'
|
||||
import PolicyTemplates from '../PolicyTemplates'
|
||||
import { PolicyTemplate } from '../PolicyTemplates/PolicyTemplates.constants'
|
||||
import { getGeneralPolicyTemplates } from './PolicyEditorModal.constants'
|
||||
import PolicyEditorModalTitle from './PolicyEditorModalTitle'
|
||||
} from '../StoragePolicies.utils'
|
||||
import { POLICY_MODAL_VIEWS } from './PolicyEditorModal.constants'
|
||||
import { PolicyEditorModalTitle } from './PolicyEditorModalTitle'
|
||||
import { useFeaturePreviewModal } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import {
|
||||
getGeneralPolicyTemplates,
|
||||
type PolicyTemplate,
|
||||
} from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
|
||||
import { useLatest } from '@/hooks/misc/useLatest'
|
||||
import { useConfirmOnClose } from '@/hooks/ui/useConfirmOnClose'
|
||||
+2
-4
@@ -2,7 +2,7 @@ import { isEmpty, noop } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { Button, DialogFooter, DialogSection } from 'ui'
|
||||
|
||||
import type { PolicyForReview } from './Policies.types'
|
||||
import type { PolicyForReview } from './StoragePolicies.types'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
|
||||
interface PolicyReviewProps {
|
||||
@@ -11,8 +11,6 @@ interface PolicyReviewProps {
|
||||
onSelectSave: () => void
|
||||
}
|
||||
|
||||
// [Joshen] This seems like dead code atm, clean up separately
|
||||
|
||||
export const PolicyReview = ({
|
||||
policy = {},
|
||||
onSelectBack = noop,
|
||||
@@ -24,7 +22,7 @@ export const PolicyReview = ({
|
||||
onSelectSave()
|
||||
}
|
||||
|
||||
let formattedSQLStatement = policy.statement || ''
|
||||
const formattedSQLStatement = policy.statement || ''
|
||||
|
||||
return (
|
||||
<>
|
||||
+1
-3
@@ -12,7 +12,7 @@ interface PolicySelectionProps {
|
||||
onToggleFeaturePreviewModal?: () => void
|
||||
}
|
||||
|
||||
const PolicySelection = ({
|
||||
export const PolicySelection = ({
|
||||
description = '',
|
||||
showAssistantPreview,
|
||||
onViewTemplates = noop,
|
||||
@@ -89,5 +89,3 @@ const PolicySelection = ({
|
||||
</DialogSection>
|
||||
)
|
||||
}
|
||||
|
||||
export default PolicySelection
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { isEmpty } from 'lodash'
|
||||
|
||||
import { PolicyTemplate } from './PolicyTemplates.constants'
|
||||
import { type PolicyTemplate } from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
|
||||
interface TemplatePreviewProps {
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { noop } from 'lodash'
|
||||
import { Menu } from 'ui'
|
||||
|
||||
import { PolicyTemplate } from './PolicyTemplates.constants'
|
||||
import { type PolicyTemplate } from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
|
||||
interface TemplatesListProps {
|
||||
templates: PolicyTemplate[]
|
||||
+2
-4
@@ -2,9 +2,9 @@ import { isEmpty, noop } from 'lodash'
|
||||
import { useState } from 'react'
|
||||
import { Button, DialogSectionSeparator } from 'ui'
|
||||
|
||||
import { PolicyTemplate } from './PolicyTemplates.constants'
|
||||
import TemplatePreview from './TemplatePreview'
|
||||
import TemplatesList from './TemplatesList'
|
||||
import { type PolicyTemplate } from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
|
||||
interface PolicyTemplatesProps {
|
||||
templates?: PolicyTemplate[]
|
||||
@@ -12,7 +12,7 @@ interface PolicyTemplatesProps {
|
||||
onUseTemplate?: (template: PolicyTemplate) => void
|
||||
}
|
||||
|
||||
const PolicyTemplates = ({
|
||||
export const PolicyTemplates = ({
|
||||
templates = [],
|
||||
templatesNote = '',
|
||||
onUseTemplate = noop,
|
||||
@@ -45,5 +45,3 @@ const PolicyTemplates = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PolicyTemplates
|
||||
@@ -16,14 +16,14 @@ import {
|
||||
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { formatPoliciesForStorage, UNGROUPED_POLICY_SYMBOL } from '../Storage.utils'
|
||||
import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow'
|
||||
import { BucketsPolicies, type SelectBucketPolicyForAction } from './StoragePoliciesBucketsSection'
|
||||
import { StoragePoliciesEditPolicyModal } from './StoragePoliciesEditPolicyModal'
|
||||
import { PolicyEditorModal } from './PolicyEditorModal'
|
||||
import type {
|
||||
PostgresPolicyCreatePayload,
|
||||
PostgresPolicyUpdatePayload,
|
||||
} from '@/components/interfaces/Database/Policies/Policies.types'
|
||||
import { PolicyEditorModal } from '@/components/interfaces/Database/Policies/PolicyEditorModal'
|
||||
} from './StoragePolicies.types'
|
||||
import { StoragePoliciesBucketRow } from './StoragePoliciesBucketRow'
|
||||
import { BucketsPolicies, type SelectBucketPolicyForAction } from './StoragePoliciesBucketsSection'
|
||||
import { StoragePoliciesEditPolicyModal } from './StoragePoliciesEditPolicyModal'
|
||||
import type { Policy } from '@/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRow.utils'
|
||||
import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
|
||||
import { useDatabasePolicyCreateMutation } from '@/data/database-policies/database-policy-create-mutation'
|
||||
|
||||
+5
@@ -58,3 +58,8 @@ export interface DraftPostgresPolicyUpdatePayload extends Omit<
|
||||
definition?: DisplayableSqlFragment
|
||||
check?: DisplayableSqlFragment
|
||||
}
|
||||
|
||||
export interface PolicyForReview {
|
||||
description?: string
|
||||
statement?: string
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { PGPolicy } from '@supabase/pg-meta'
|
||||
import { untrustedSql } from '@supabase/pg-meta'
|
||||
import { has, isEmpty, isEqual } from 'lodash'
|
||||
|
||||
import {
|
||||
DraftPostgresPolicyCreatePayload,
|
||||
DraftPostgresPolicyUpdatePayload,
|
||||
PolicyFormField,
|
||||
PolicyForReview,
|
||||
} from './StoragePolicies.types'
|
||||
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
|
||||
// These constructors return DRAFT payloads — `definition`/`check` are still
|
||||
// `DisplayableSqlFragment`. Promotion to `SafeSqlFragment` must happen at the user gesture
|
||||
// (the Save click in `PolicyEditorModal`), not here, since this module has no guarantee that
|
||||
// it was reached via a deliberate user action.
|
||||
export const createPayloadForCreatePolicy = (
|
||||
policyFormFields: PolicyFormField
|
||||
): DraftPostgresPolicyCreatePayload => {
|
||||
const { name, schema, table, command, definition, check, roles } = policyFormFields
|
||||
return {
|
||||
name,
|
||||
schema,
|
||||
table,
|
||||
action: 'PERMISSIVE',
|
||||
command: command || undefined,
|
||||
definition: !definition ? undefined : untrustedSql(definition),
|
||||
check: !check ? undefined : untrustedSql(check),
|
||||
roles: roles.length > 0 ? roles : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const createPayloadForUpdatePolicy = (
|
||||
policyFormFields: PolicyFormField,
|
||||
originalPolicyFormFields: PGPolicy
|
||||
): DraftPostgresPolicyUpdatePayload => {
|
||||
const { definition, check } = policyFormFields
|
||||
const formattedDefinition = definition ? definition.replace(/\s+/g, ' ').trim() : definition
|
||||
const formattedCheck = check ? check.replace(/\s+/g, ' ').trim() : check
|
||||
|
||||
const payload: DraftPostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id }
|
||||
|
||||
if (!isEqual(policyFormFields.name, originalPolicyFormFields.name)) {
|
||||
payload.name = policyFormFields.name
|
||||
}
|
||||
if (!isEqual(formattedDefinition, originalPolicyFormFields.definition)) {
|
||||
payload.definition = !formattedDefinition ? undefined : untrustedSql(formattedDefinition)
|
||||
}
|
||||
if (!isEqual(formattedCheck, originalPolicyFormFields.check)) {
|
||||
payload.check = !formattedCheck ? undefined : untrustedSql(formattedCheck)
|
||||
}
|
||||
if (!isEqual(policyFormFields.roles, originalPolicyFormFields.roles)) {
|
||||
if (policyFormFields.roles.length === 0) payload.roles = ['public']
|
||||
else payload.roles = policyFormFields.roles || undefined
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
+5
-5
@@ -8,13 +8,13 @@ import {
|
||||
createPayloadsForAddPolicy,
|
||||
createSQLPolicies,
|
||||
} from '../Storage.utils'
|
||||
import { POLICY_MODAL_VIEWS } from './PolicyEditorModal/PolicyEditorModal.constants'
|
||||
import { PolicySelection } from './PolicySelection'
|
||||
import { PolicyTemplates } from './PolicyTemplates'
|
||||
import { STORAGE_POLICY_TEMPLATES } from './StoragePolicies.constants'
|
||||
import StoragePoliciesEditor from './StoragePoliciesEditor'
|
||||
import StoragePoliciesReview from './StoragePoliciesReview'
|
||||
import { StoragePoliciesEditor } from './StoragePoliciesEditor'
|
||||
import { StoragePoliciesReview } from './StoragePoliciesReview'
|
||||
import { StoragePolicyEditorModalTitle } from './StoragePolicyEditorModalTitle'
|
||||
import { POLICY_MODAL_VIEWS } from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
import PolicySelection from '@/components/interfaces/Database/Policies/PolicySelection'
|
||||
import PolicyTemplates from '@/components/interfaces/Database/Policies/PolicyTemplates'
|
||||
|
||||
const newPolicyTemplate: any = {
|
||||
name: '',
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Button, Checkbox, cn, DialogSection, DialogSectionSeparator } from 'ui'
|
||||
|
||||
import { STORAGE_CLIENT_LIBRARY_MAPPINGS } from '../Storage.constants'
|
||||
import { deriveAllowedClientLibraryMethods } from '../Storage.utils'
|
||||
import { PolicyName } from '@/components/interfaces/Database/Policies/PolicyEditor/PolicyName'
|
||||
import { PolicyRoles } from '@/components/interfaces/Database/Policies/PolicyEditor/PolicyRoles'
|
||||
import { PolicyName } from './PolicyEditor/PolicyName'
|
||||
import { PolicyRoles } from './PolicyEditor/PolicyRoles'
|
||||
import { CodeEditor } from '@/components/ui/CodeEditor/CodeEditor'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
@@ -149,7 +149,7 @@ const PolicyEditorFooter = ({ onViewTemplates = () => {}, onReviewPolicy = () =>
|
||||
|
||||
// [Refactor] All these update methods could be summarised into one single function probably
|
||||
|
||||
const StoragePoliciesEditor = ({
|
||||
export const StoragePoliciesEditor = ({
|
||||
policyFormFields = {},
|
||||
onViewTemplates = noop,
|
||||
onUpdatePolicyName = noop,
|
||||
@@ -194,5 +194,3 @@ const StoragePoliciesEditor = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default StoragePoliciesEditor
|
||||
@@ -17,7 +17,7 @@ interface StoragePoliciesReviewProps {
|
||||
onSelectSave: any
|
||||
}
|
||||
|
||||
const StoragePoliciesReview = ({
|
||||
export const StoragePoliciesReview = ({
|
||||
policyStatements = [],
|
||||
onSelectBack = () => {},
|
||||
onSelectSave = () => {},
|
||||
@@ -74,5 +74,3 @@ const StoragePoliciesReview = ({
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default StoragePoliciesReview
|
||||
+1
-1
@@ -3,7 +3,7 @@ import { ChevronLeft, X } from 'lucide-react'
|
||||
import { Dialog as DialogPrimitive } from 'radix-ui'
|
||||
import { cn } from 'ui'
|
||||
|
||||
import { POLICY_MODAL_VIEWS } from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
import { POLICY_MODAL_VIEWS } from './PolicyEditorModal/PolicyEditorModal.constants'
|
||||
import { DocsButton } from '@/components/ui/DocsButton'
|
||||
import { DOCS_URL } from '@/lib/constants'
|
||||
|
||||
|
||||
+2
-35
@@ -37,10 +37,6 @@ import type { ImportContent } from './TableEditor/TableEditor.types'
|
||||
import { useTableRowOperations } from '@/components/grid/hooks/useTableRowOperations'
|
||||
import { getStableRowIdentifiers } from '@/components/grid/utils/queueOperationUtils'
|
||||
import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
|
||||
import {
|
||||
acceptGeneratedPolicy,
|
||||
type GeneratedPolicy,
|
||||
} from '@/components/interfaces/Database/Policies/Policies.utils'
|
||||
import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog'
|
||||
import { databasePoliciesKeys } from '@/data/database-policies/keys'
|
||||
import { useDatabasePublicationCreateMutation } from '@/data/database-publications/database-publications-create-mutation'
|
||||
@@ -83,7 +79,6 @@ type SaveTableParamsBase = {
|
||||
columns: ColumnField[]
|
||||
foreignKeyRelations: ForeignKey[]
|
||||
resolve: () => void
|
||||
generatedPolicies?: GeneratedPolicy[]
|
||||
}
|
||||
|
||||
type SaveTableParamsNew = SaveTableParamsBase & {
|
||||
@@ -598,7 +593,6 @@ export const SidePanelEditor = ({
|
||||
configuration,
|
||||
columns,
|
||||
foreignKeyRelations,
|
||||
generatedPolicies = [],
|
||||
resolve,
|
||||
}: SaveTableParams) => {
|
||||
let toastId
|
||||
@@ -663,7 +657,6 @@ export const SidePanelEditor = ({
|
||||
'table.has_rls': isRLSEnabled ? 1 : 0,
|
||||
'table.has_foreign_keys': foreignKeyRelations.length > 0 ? 1 : 0,
|
||||
'table.has_import': importContent !== undefined ? 1 : 0,
|
||||
'table.generated_policies_count': generatedPolicies.length,
|
||||
'project.region': project?.region ?? 'local',
|
||||
...(project?.cloud_provider && {
|
||||
'project.cloud_provider': project.cloud_provider,
|
||||
@@ -674,13 +667,7 @@ export const SidePanelEditor = ({
|
||||
})
|
||||
|
||||
try {
|
||||
// The Save click is the explicit user gesture that promotes generated policy
|
||||
// SQL (programmatic or AI) to executable. Programmatic fragments are already
|
||||
// SafeSqlFragment; AI fragments are UntrustedSqlFragment — both are accepted
|
||||
// here before being passed into createTable.
|
||||
const acceptedPolicies = generatedPolicies.map(acceptGeneratedPolicy)
|
||||
|
||||
const { table, failedPolicies } = await createTable({
|
||||
const { table } = await createTable({
|
||||
projectRef: project?.ref!,
|
||||
connectionString: project?.connectionString,
|
||||
toastId,
|
||||
@@ -689,13 +676,10 @@ export const SidePanelEditor = ({
|
||||
foreignKeyRelations,
|
||||
isRLSEnabled,
|
||||
importContent,
|
||||
generatedPolicies: acceptedPolicies,
|
||||
onCreatePoliciesSuccess: () => track('rls_generated_policies_created'),
|
||||
track,
|
||||
})
|
||||
|
||||
createTableSpan.setAttribute('table.created', 1)
|
||||
createTableSpan.setAttribute('table.failed_policies', failedPolicies.length)
|
||||
|
||||
await Sentry.startSpan(
|
||||
{ name: 'create_table.post_creation', op: 'db.table.post_creation' },
|
||||
@@ -736,24 +720,7 @@ export const SidePanelEditor = ({
|
||||
}
|
||||
)
|
||||
|
||||
// Show success toast after everything is complete
|
||||
if (failedPolicies.length > 0) {
|
||||
toast.success(
|
||||
`Table ${table.name} is created successfully, but we ran into issues creating ${failedPolicies.length} policie${failedPolicies.length > 1 ? 's' : ''}`,
|
||||
{
|
||||
id: toastId,
|
||||
description: (
|
||||
<ul className="list-disc pl-6">
|
||||
{failedPolicies.map((x) => (
|
||||
<li key={x.name}>{x.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
}
|
||||
)
|
||||
} else {
|
||||
toast.success(`Table ${table.name} is good to go!`, { id: toastId })
|
||||
}
|
||||
toast.success(`Table ${table.name} is good to go!`, { id: toastId })
|
||||
|
||||
onTableCreated(table)
|
||||
} catch (error) {
|
||||
|
||||
-2
@@ -132,7 +132,6 @@ describe('createTable', () => {
|
||||
})
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith('table_created', {
|
||||
has_generated_policies: false,
|
||||
method: 'table_editor',
|
||||
schema_name: 'public',
|
||||
table_name: 'test_table',
|
||||
@@ -148,7 +147,6 @@ describe('createTable', () => {
|
||||
)
|
||||
|
||||
expect(result).toStrictEqual({
|
||||
failedPolicies: [],
|
||||
table: mockTableResult,
|
||||
})
|
||||
})
|
||||
|
||||
+2
-49
@@ -1,4 +1,5 @@
|
||||
import * as Sentry from '@sentry/nextjs'
|
||||
import type { PGTablePrimaryKey } from '@supabase/pg-meta'
|
||||
import pgMeta, {
|
||||
getAddForeignKeySQL,
|
||||
getAddPrimaryKeySQL,
|
||||
@@ -11,7 +12,6 @@ import pgMeta, {
|
||||
getUpdateIdentitySequenceSQL,
|
||||
type ForeignKey,
|
||||
} from '@supabase/pg-meta'
|
||||
import type { PGTablePrimaryKey } from '@supabase/pg-meta'
|
||||
import { joinSqlFragments, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format'
|
||||
import { Query } from '@supabase/pg-meta/src/query'
|
||||
import { chunk, find, isEmpty, isEqual } from 'lodash'
|
||||
@@ -26,12 +26,10 @@ import type { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './Si
|
||||
import { checkIfRelationChanged } from './TableEditor/ForeignKeysManagement/ForeignKeysManagement.utils'
|
||||
import type { ImportContent } from './TableEditor/TableEditor.types'
|
||||
import type { SupaRow } from '@/components/grid/types'
|
||||
import { type AcceptedGeneratedPolicy } from '@/components/interfaces/Database/Policies/Policies.utils'
|
||||
import { SparkBar } from '@/components/ui/SparkBar'
|
||||
import { createDatabaseColumn } from '@/data/database-columns/database-column-create-mutation'
|
||||
import { deleteDatabaseColumn } from '@/data/database-columns/database-column-delete-mutation'
|
||||
import { updateDatabaseColumn } from '@/data/database-columns/database-column-update-mutation'
|
||||
import { createDatabasePolicy } from '@/data/database-policies/database-policy-create-mutation'
|
||||
import type { Constraint } from '@/data/database/constraints-query'
|
||||
import { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query'
|
||||
import { databaseKeys } from '@/data/database/keys'
|
||||
@@ -443,8 +441,6 @@ export const createTable = async ({
|
||||
foreignKeyRelations,
|
||||
isRLSEnabled,
|
||||
importContent,
|
||||
generatedPolicies = [],
|
||||
onCreatePoliciesSuccess,
|
||||
track,
|
||||
}: {
|
||||
projectRef: string
|
||||
@@ -459,8 +455,6 @@ export const createTable = async ({
|
||||
foreignKeyRelations: ForeignKey[]
|
||||
isRLSEnabled: boolean
|
||||
importContent?: ImportContent
|
||||
generatedPolicies?: AcceptedGeneratedPolicy[]
|
||||
onCreatePoliciesSuccess?: () => void
|
||||
track: Track
|
||||
}) => {
|
||||
const queryClient = getQueryClient()
|
||||
@@ -545,51 +539,10 @@ export const createTable = async ({
|
||||
}
|
||||
)
|
||||
|
||||
// 6. Create generated RLS policies if any
|
||||
// [Joshen] Possible area for optimization to create all policies in a single query call
|
||||
// Can be subsequently added to the table creation SQL as well for a single transaction
|
||||
|
||||
const failedPolicies: AcceptedGeneratedPolicy[] = []
|
||||
if (generatedPolicies.length > 0 && isRLSEnabled) {
|
||||
await Sentry.startSpan(
|
||||
{ name: 'create_table.create_policies', op: 'db.policies.create' },
|
||||
async (span) => {
|
||||
span.setAttribute('policies.count', generatedPolicies.length)
|
||||
toast.loading(`Creating ${generatedPolicies.length} policies for table...`, { id: toastId })
|
||||
await Promise.all(
|
||||
generatedPolicies.map(async (policy) => {
|
||||
try {
|
||||
return await createDatabasePolicy({
|
||||
projectRef,
|
||||
connectionString,
|
||||
payload: {
|
||||
name: policy.name,
|
||||
table: policy.table,
|
||||
schema: policy.schema,
|
||||
definition: policy.definition,
|
||||
check: policy.check,
|
||||
action: policy.action,
|
||||
command: policy.command,
|
||||
roles: policy.roles,
|
||||
},
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to generate policy', error.message)
|
||||
failedPolicies.push(policy)
|
||||
}
|
||||
})
|
||||
)
|
||||
span.setAttribute('policies.failed_count', failedPolicies.length)
|
||||
onCreatePoliciesSuccess?.()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
track('table_created', {
|
||||
method: 'table_editor',
|
||||
schema_name: payload.schema,
|
||||
table_name: payload.name,
|
||||
has_generated_policies: generatedPolicies.length > 0 && isRLSEnabled,
|
||||
})
|
||||
|
||||
if (isRLSEnabled) {
|
||||
@@ -726,7 +679,7 @@ export const createTable = async ({
|
||||
)
|
||||
|
||||
// Finally, return the created table
|
||||
return { table, failedPolicies }
|
||||
return { table }
|
||||
}
|
||||
|
||||
/** TODO: Refactor to do in a single transaction */
|
||||
|
||||
-3
@@ -242,7 +242,6 @@ export const TableEditor = ({
|
||||
columns,
|
||||
foreignKeyRelations: fkRelations,
|
||||
resolve,
|
||||
generatedPolicies: [],
|
||||
})
|
||||
} else if (isDuplicating) {
|
||||
const payload: SaveTablePayloadFor<'duplicate'> = {
|
||||
@@ -256,7 +255,6 @@ export const TableEditor = ({
|
||||
columns,
|
||||
foreignKeyRelations: fkRelations,
|
||||
resolve,
|
||||
generatedPolicies: [],
|
||||
})
|
||||
} else {
|
||||
const payload: SaveTablePayloadFor<'update'> = {
|
||||
@@ -271,7 +269,6 @@ export const TableEditor = ({
|
||||
columns,
|
||||
foreignKeyRelations: fkRelations,
|
||||
resolve,
|
||||
generatedPolicies: [],
|
||||
})
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -23,8 +23,8 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
import { useIsInlineEditorEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
|
||||
import { useIsRLSTesterEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
|
||||
import { Policies } from '@/components/interfaces/Database/Policies/Policies'
|
||||
import { getGeneralPolicyTemplates } from '@/components/interfaces/Database/Policies/Policies.constants'
|
||||
import { PoliciesDataProvider } from '@/components/interfaces/Database/Policies/PoliciesDataContext'
|
||||
import { getGeneralPolicyTemplates } from '@/components/interfaces/Database/Policies/PolicyEditorModal/PolicyEditorModal.constants'
|
||||
import { PolicyEditorPanel } from '@/components/interfaces/Database/Policies/PolicyEditorPanel'
|
||||
import {
|
||||
generatePolicyUpdateSQL,
|
||||
|
||||
Reference in new issue
Block a user