diff --git a/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx b/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx index 9d7e424c19c..438fe9fe3d0 100644 --- a/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx +++ b/apps/studio/components/interfaces/Database/Roles/RoleRow.tsx @@ -11,7 +11,6 @@ import { CollapsibleTrigger, DropdownMenu, DropdownMenuContent, - DropdownMenuItem, DropdownMenuTrigger, Form, FormControl, @@ -24,7 +23,8 @@ import { import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import * as z from 'zod' -import { ROLE_PERMISSIONS } from './Roles.constants' +import { ROLE_PERMISSIONS, SYSTEM_ROLES } from './Roles.constants' +import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' import { useDatabaseRoleUpdateMutation } from '@/data/database-roles/database-role-update-mutation' import type { PgRole } from '@/data/database-roles/database-roles-query' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -47,6 +47,7 @@ const formSchema = z.object( ) export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps) => { + const isSystemRole = SYSTEM_ROLES.some((name) => name === role.name) const { data: project } = useSelectedProjectQuery() const [isExpanded, setIsExpanded] = useState(false) const { mutate: updateDatabaseRole, isPending: isUpdating } = useDatabaseRoleUpdateMutation() @@ -171,8 +172,15 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps More options - { event.stopPropagation() onSelectDelete(role.id.toString()) @@ -180,7 +188,7 @@ export const RoleRow = ({ role, disabled = false, onSelectDelete }: RoleRowProps >

Delete

-
+
)} diff --git a/apps/studio/components/interfaces/Database/Roles/Roles.constants.ts b/apps/studio/components/interfaces/Database/Roles/Roles.constants.ts index 1d108d2440b..0766d73c9e9 100644 --- a/apps/studio/components/interfaces/Database/Roles/Roles.constants.ts +++ b/apps/studio/components/interfaces/Database/Roles/Roles.constants.ts @@ -5,6 +5,7 @@ export const SUPABASE_ROLES = [ 'authenticator', 'dashboard_user', 'supabase_admin', + 'supabase_privileged_role', 'supabase_auth_admin', 'supabase_functions_admin', 'supabase_read_only_user', diff --git a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx index a4c73cb2bf3..e1c00721df5 100644 --- a/apps/studio/components/interfaces/Database/Roles/RolesList.tsx +++ b/apps/studio/components/interfaces/Database/Roles/RolesList.tsx @@ -248,9 +248,11 @@ export const RolesList = () => {
-
+
-

Roles managed by Supabase

+

+ Supabase service roles +

Protected
@@ -264,11 +266,18 @@ export const RolesList = () => { onSelectDelete={setSelectedRoleIdToDelete} /> ))} -
+ -
+
-

Other database roles

+
+

+ Project database roles +

+

+ Includes the postgres administrator role and custom roles. +

+
{isLoading @@ -281,7 +290,7 @@ export const RolesList = () => { onSelectDelete={setSelectedRoleIdToDelete} /> ))} -
+
{filterString.length > 0 && filteredRoles.length === 0 && ( diff --git a/apps/studio/tests/components/Database/Roles.test.tsx b/apps/studio/tests/components/Database/Roles.test.tsx new file mode 100644 index 00000000000..2428bacaeec --- /dev/null +++ b/apps/studio/tests/components/Database/Roles.test.tsx @@ -0,0 +1,159 @@ +import { fireEvent, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { mockAnimationsApi } from 'jsdom-testing-mocks' +import { HttpResponse } from 'msw' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { RoleRow } from '@/components/interfaces/Database/Roles/RoleRow' +import { RolesList } from '@/components/interfaces/Database/Roles/RolesList' +import type { PgRole } from '@/data/database-roles/database-roles-query' +import type { ProjectDetail } from '@/data/projects/project-detail-query' +import { customRender } from '@/tests/lib/custom-render' +import { addAPIMock } from '@/tests/lib/msw' + +mockAnimationsApi() + +const createRole = (id: number, name: string): PgRole => ({ + id, + name, + isSuperuser: false, + canCreateDb: false, + canCreateRole: false, + inheritRole: true, + canLogin: true, + isReplicationRole: false, + canBypassRls: false, + activeConnections: 0, + connectionLimit: -1, + validUntil: null, + config: {}, +}) + +const roles = [ + createRole(1, 'postgres'), + createRole(2, 'supabase_privileged_role'), + createRole(3, 'supabase_auth_admin'), + createRole(4, 'custom_role'), +] + +beforeEach(() => { + addAPIMock({ + method: 'get', + path: '/platform/projects/:ref', + response: () => + HttpResponse.json({ + cloud_provider: 'AWS', + connectionString: null, + db_host: 'localhost', + high_availability: false, + id: 1, + inserted_at: '2026-01-01T00:00:00Z', + integration_source: null, + is_branch_enabled: false, + is_physical_backups_enabled: false, + name: 'Test project', + organization_id: 1, + ref: 'default', + region: 'us-east-1', + restUrl: 'http://localhost:54321', + status: 'ACTIVE_HEALTHY', + subscription_id: 'test-subscription', + updated_at: '2026-01-01T00:00:00Z', + }), + }) + + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: ({ request }) => { + const key = new URL(request.url).searchParams.get('key') + if (key === 'max-connections') { + return HttpResponse.json<{ max_connections: string }[]>([{ max_connections: '100' }]) + } + if (key === 'database-roles') { + return HttpResponse.json(roles) + } + throw new Error(`Unexpected SQL request: ${key}`) + }, + }) +}) + +describe('RoleRow', () => { + test.each(['postgres', 'pg_database_owner', 'pg_read_all_data', 'pg_write_all_data'])( + 'disables Delete for the system role %s', + async (name) => { + const onSelectDelete = vi.fn() + customRender() + + await userEvent.click(screen.getByRole('button', { name: `${name} actions` })) + const deleteItem = await screen.findByRole('menuitem', { name: 'Delete' }) + expect(deleteItem).toBeAriaDisabled() + fireEvent.click(deleteItem) + expect(onSelectDelete).not.toHaveBeenCalled() + + await userEvent.hover(deleteItem) + expect( + await screen.findByRole('tooltip', { + name: `The ${name} role is a system role and cannot be deleted`, + }) + ).toBeInTheDocument() + } + ) + + test('keeps the postgres permission editor available', async () => { + customRender() + + await userEvent.click(screen.getByRole('button', { name: /postgres \(ID: 1\)/ })) + const loginSwitch = await screen.findByRole('switch', { name: 'User can login' }) + expect(loginSwitch).not.toBeDisabled() + await userEvent.click(loginSwitch) + expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled() + }) + + test('allows selecting a custom role for deletion', async () => { + const onSelectDelete = vi.fn() + customRender() + + await userEvent.click(screen.getByRole('button', { name: 'custom_role actions' })) + const deleteItem = await screen.findByRole('menuitem', { name: 'Delete' }) + expect(deleteItem).not.toBeAriaDisabled() + await userEvent.click(deleteItem) + expect(onSelectDelete).toHaveBeenCalledWith('4') + }) + + test('keeps disabled roles read-only', async () => { + customRender() + + expect(screen.queryByRole('button', { name: 'custom_role actions' })).not.toBeInTheDocument() + await userEvent.click(screen.getByRole('button', { name: /custom_role \(ID: 4\)/ })) + expect(await screen.findByRole('switch', { name: 'User can login' })).toBeDisabled() + expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument() + }) +}) + +describe('RolesList', () => { + test('separates protected service roles from the project administrator and custom roles', async () => { + customRender() + + const serviceRoles = within(screen.getByRole('region', { name: 'Supabase service roles' })) + const projectRoles = within(screen.getByRole('region', { name: 'Project database roles' })) + expect(await serviceRoles.findByText('supabase_privileged_role')).toBeInTheDocument() + expect(serviceRoles.getByText('supabase_auth_admin')).toBeInTheDocument() + expect(serviceRoles.queryByText('postgres')).not.toBeInTheDocument() + expect(projectRoles.getByRole('button', { name: /postgres \(ID: 1\)/ })).toBeInTheDocument() + expect(projectRoles.getByRole('button', { name: /custom_role \(ID: 4\)/ })).toBeInTheDocument() + expect( + serviceRoles.queryByRole('button', { name: 'supabase_privileged_role actions' }) + ).not.toBeInTheDocument() + }) + + test('opens the deletion confirmation for a custom role', async () => { + customRender() + + await userEvent.click(await screen.findByRole('button', { name: 'custom_role actions' })) + await userEvent.click(await screen.findByRole('menuitem', { name: 'Delete' })) + expect(await screen.findByRole('dialog')).toHaveTextContent( + 'Confirm to delete role "custom_role"' + ) + }) +})