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"'
+ )
+ })
+})