diff --git a/apps/studio/components/interfaces/Database/Policies/Policies.constants.ts b/apps/studio/components/interfaces/Database/Policies/Policies.constants.ts index 060d9ee81c3..2e1e7dcb2a7 100644 --- a/apps/studio/components/interfaces/Database/Policies/Policies.constants.ts +++ b/apps/studio/components/interfaces/Database/Policies/Policies.constants.ts @@ -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 +} + +/** + * ---------------------------------------------------------------- + * 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'], + }, + ] } diff --git a/apps/studio/components/interfaces/Database/Policies/Policies.utils.test.ts b/apps/studio/components/interfaces/Database/Policies/Policies.utils.test.ts deleted file mode 100644 index e785b11c2b9..00000000000 --- a/apps/studio/components/interfaces/Database/Policies/Policies.utils.test.ts +++ /dev/null @@ -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 => ({ - 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() - }) - }) -}) diff --git a/apps/studio/components/interfaces/Database/Policies/Policies.utils.ts b/apps/studio/components/interfaces/Database/Policies/Policies.utils.ts deleted file mode 100644 index d98c9322558..00000000000 --- a/apps/studio/components/interfaces/Database/Policies/Policies.utils.ts +++ /dev/null @@ -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 -): 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 -> & - Pick & { - 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 & { - 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() - 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( - (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 => { - 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 => { - // 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 [] -} diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts b/apps/studio/components/interfaces/Database/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts deleted file mode 100644 index 9e918f6cc11..00000000000 --- a/apps/studio/components/interfaces/Database/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts +++ /dev/null @@ -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'], - }, - ] -} diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx index ea7da926e6f..77d54208dad 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx @@ -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' diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index fc4df51adea..36ef1d8dc4d 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -56,24 +56,26 @@ export const PolicyTableRowHeader = ({ > {table.name} + +
{!table.rls_enabled && ( - + RLS Disabled )} {!isLoadingApiAccess && !hasApiAccess && ( - + API Disabled )} - - {isTableLocked && ( - - - Locked - - - )} + {isTableLocked && ( + + + Locked + + + )} +
{!isTableLocked && (
diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx index 8098b16c72e..2e60637e59b 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx @@ -94,7 +94,9 @@ const PolicyTableRowComponent = ({ return ( - + -} diff --git a/apps/studio/components/interfaces/Storage/Storage.types.ts b/apps/studio/components/interfaces/Storage/Storage.types.ts index d18cf5f3228..3b55265bca9 100644 --- a/apps/studio/components/interfaces/Storage/Storage.types.ts +++ b/apps/studio/components/interfaces/Storage/Storage.types.ts @@ -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[] diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditor/PolicyAllowedOperation.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyAllowedOperation.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/Policies/PolicyEditor/PolicyAllowedOperation.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyAllowedOperation.tsx diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditor/PolicyDefinition.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyDefinition.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/Policies/PolicyEditor/PolicyDefinition.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyDefinition.tsx diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditor/PolicyEditorFooter.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyEditorFooter.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/Policies/PolicyEditor/PolicyEditorFooter.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyEditorFooter.tsx diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditor/PolicyName.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyName.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/Policies/PolicyEditor/PolicyName.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyName.tsx diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditor/PolicyRoles.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyRoles.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/Policies/PolicyEditor/PolicyRoles.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/PolicyRoles.tsx diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditor/index.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/index.tsx similarity index 100% rename from apps/studio/components/interfaces/Database/Policies/PolicyEditor/index.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditor/index.tsx diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/PolicyEditorModal.constants.ts b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/PolicyEditorModal.constants.ts new file mode 100644 index 00000000000..060d9ee81c3 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/PolicyEditorModal.constants.ts @@ -0,0 +1,6 @@ +export const POLICY_MODAL_VIEWS = { + SELECTION: 'SELECTION', + TEMPLATES: 'TEMPLATES', + EDITOR: 'EDITOR', + REVIEW: 'REVIEW', +} diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorModal/PolicyEditorModalTitle.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/PolicyEditorModalTitle.tsx similarity index 93% rename from apps/studio/components/interfaces/Database/Policies/PolicyEditorModal/PolicyEditorModalTitle.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/PolicyEditorModalTitle.tsx index ba194252cb3..044a4acaffd 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyEditorModal/PolicyEditorModalTitle.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/PolicyEditorModalTitle.tsx @@ -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 = ({
) } - -export default PolicyEditorModalTitle diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorModal/index.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/index.tsx similarity index 95% rename from apps/studio/components/interfaces/Database/Policies/PolicyEditorModal/index.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/index.tsx index d7b09c6416b..7a82ff90e73 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyEditorModal/index.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyEditorModal/index.tsx @@ -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' diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyReview.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyReview.tsx similarity index 92% rename from apps/studio/components/interfaces/Database/Policies/PolicyReview.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyReview.tsx index 77f27382bb0..a22c7c18ab2 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyReview.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyReview.tsx @@ -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 ( <> diff --git a/apps/studio/components/interfaces/Database/Policies/PolicySelection.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicySelection.tsx similarity index 98% rename from apps/studio/components/interfaces/Database/Policies/PolicySelection.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicySelection.tsx index c1a7fc666cf..06dc7a89ade 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicySelection.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicySelection.tsx @@ -12,7 +12,7 @@ interface PolicySelectionProps { onToggleFeaturePreviewModal?: () => void } -const PolicySelection = ({ +export const PolicySelection = ({ description = '', showAssistantPreview, onViewTemplates = noop, @@ -89,5 +89,3 @@ const PolicySelection = ({ ) } - -export default PolicySelection diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyTemplates/TemplatePreview.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/TemplatePreview.tsx similarity index 92% rename from apps/studio/components/interfaces/Database/Policies/PolicyTemplates/TemplatePreview.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/TemplatePreview.tsx index 62520e18639..4e4effeaa91 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyTemplates/TemplatePreview.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/TemplatePreview.tsx @@ -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 { diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyTemplates/TemplatesList.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/TemplatesList.tsx similarity index 93% rename from apps/studio/components/interfaces/Database/Policies/PolicyTemplates/TemplatesList.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/TemplatesList.tsx index 9e7afbcb273..4ece27c1cc1 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyTemplates/TemplatesList.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/TemplatesList.tsx @@ -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[] diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyTemplates/index.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/index.tsx similarity index 91% rename from apps/studio/components/interfaces/Database/Policies/PolicyTemplates/index.tsx rename to apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/index.tsx index 201e0adbb9f..4c1157a19df 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyTemplates/index.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/PolicyTemplates/index.tsx @@ -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 = ({ ) } - -export default PolicyTemplates diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx index 64dfb572535..79a7110b4b7 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.tsx @@ -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' diff --git a/apps/studio/components/interfaces/Database/Policies/Policies.types.ts b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.types.ts similarity index 95% rename from apps/studio/components/interfaces/Database/Policies/Policies.types.ts rename to apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.types.ts index 911fb6426fc..4461bfd9fbb 100644 --- a/apps/studio/components/interfaces/Database/Policies/Policies.types.ts +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.types.ts @@ -58,3 +58,8 @@ export interface DraftPostgresPolicyUpdatePayload extends Omit< definition?: DisplayableSqlFragment check?: DisplayableSqlFragment } + +export interface PolicyForReview { + description?: string + statement?: string +} diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.utils.ts b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.utils.ts new file mode 100644 index 00000000000..a9131a949ad --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicies.utils.ts @@ -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 +): 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 +} diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx index e254b2d4cd0..070b1c814e4 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditPolicyModal.tsx @@ -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: '', diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditor.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditor.tsx index 74fa9482b89..981c35abacd 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditor.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesEditor.tsx @@ -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 diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesReview.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesReview.tsx index 02e3d301596..d4b687a3c5b 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesReview.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePoliciesReview.tsx @@ -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 diff --git a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicyEditorModalTitle.tsx b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicyEditorModalTitle.tsx index 6278433c9a6..7138302232b 100644 --- a/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicyEditorModalTitle.tsx +++ b/apps/studio/components/interfaces/Storage/StoragePolicies/StoragePolicyEditorModalTitle.tsx @@ -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' diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index ec146b9d980..05a0d70b445 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -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: ( -
    - {failedPolicies.map((x) => ( -
  • {x.name}
  • - ))} -
- ), - } - ) - } 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) { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts index 02e843e0f2c..2f5b366797a 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts @@ -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, }) }) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index 5221b41011d..63b72069d9d 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -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 */ diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index 86d0a076fd0..a8313d0cd1a 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -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 { diff --git a/apps/studio/pages/project/[ref]/database/policies.tsx b/apps/studio/pages/project/[ref]/database/policies.tsx index fec4d768ee9..654a48de3fb 100644 --- a/apps/studio/pages/project/[ref]/database/policies.tsx +++ b/apps/studio/pages/project/[ref]/database/policies.tsx @@ -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,