feat: allow disabling and re-enabling of anon and service_role (#36395)

* feat: allow disabling and re-enabling of `anon` and `service_role`

* Cleanup the RQ hooks.

* Restructure the enable/disable alert in API keys.

* Minor cleanups.

* Minor fixes.

* Fix a type error.

---------

Co-authored-by: Ivan Vasilov <vasilov.ivan@gmail.com>
This commit is contained in:
Stojan DimitrovskiandIvan Vasilov authored and GitHub committed 2025-06-16 17:46:34 +02:00
1 parent d6aa546f3e
commit 7c3f79578e
12 files changed
+358 -25

No files matched your search

@@ -8,7 +8,6 @@ import { useParams } from 'common'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import DatabaseSelector from 'components/ui/DatabaseSelector'
import Panel from 'components/ui/Panel'
import { DisplayApiSettings } from 'components/ui/ProjectSettings'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query'
import { configKeys } from 'data/config/keys'
@@ -19,9 +18,9 @@ import { PROJECT_STATUS } from 'lib/constants'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { Badge, Input } from 'ui'
import { JWT_SECRET_UPDATE_ERROR_MESSAGES } from './API.constants'
import { ApiKeysMoved } from './ApiKeysMoved'
import JWTSettings from './JWTSettings'
import { PostgrestConfig } from './PostgrestConfig'
import { ApiKeysMoved } from './ApiKeysMoved'
const ServiceList = () => {
const client = useQueryClient()
@@ -122,8 +122,8 @@ const BuildingState = () => {
</ul>
</div>
</div>
<div className="col-span-12 lg:col-span-8">
<DisplayApiSettings />
<div className="col-span-12 lg:col-span-8 flex flex-col gap-8">
<DisplayApiSettings showLegacyText={false} />
<DisplayConfigSettings />
</div>
</div>
@@ -1,23 +1,27 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { JwtSecretUpdateStatus } from '@supabase/shared-types/out/events'
import { AlertCircle, Loader2 } from 'lucide-react'
import { useMemo } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import Panel from 'components/ui/Panel'
import { useJwtSecretUpdatingStatusQuery } from 'data/config/jwt-secret-updating-status-query'
import { useProjectSettingsV2Query } from 'data/config/project-settings-v2-query'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import { useFlag } from 'hooks/ui/useFlag'
import { AlertCircle, Loader2 } from 'lucide-react'
import { useMemo } from 'react'
import { toast } from 'sonner'
import { Input } from 'ui'
import { getLastUsedAPIKeys, useLastUsedAPIKeysLogQuery } from './DisplayApiSettings.utils'
import { ToggleLegacyApiKeysPanel } from './ToggleLegacyApiKeys'
const DisplayApiSettings = ({
legacy,
export const DisplayApiSettings = ({
showTitle = true,
showNotice = true,
showLegacyText = true,
}: {
legacy?: boolean
showTitle?: boolean
showNotice?: boolean
showLegacyText?: boolean
}) => {
const { ref: projectRef } = useParams()
@@ -35,13 +39,16 @@ const DisplayApiSettings = ({
} = useJwtSecretUpdatingStatusQuery({ projectRef })
const jwtSecretUpdateStatus = data?.jwtSecretUpdateStatus
const { isLoading, can: canReadAPIKeys } = useAsyncCheckProjectPermissions(
const { isLoading: isLoadingPermissions, can: canReadAPIKeys } = useAsyncCheckProjectPermissions(
PermissionAction.READ,
'service_api_keys'
)
const isLoading = isProjectSettingsLoading || isLoadingPermissions
const isNotUpdatingJwtSecret =
jwtSecretUpdateStatus === undefined || jwtSecretUpdateStatus === JwtSecretUpdateStatus.Updated
const apiKeys = settings?.service_api_keys ?? []
const apiKeys = useMemo(() => settings?.service_api_keys ?? [], [settings])
// api keys should not be empty. However it can be populated with a delay on project creation
const isApiKeysEmpty = apiKeys.length === 0
@@ -66,8 +73,9 @@ const DisplayApiSettings = ({
return (
<>
<Panel
noMargin
title={
!legacy && (
showTitle && (
<div className="space-y-3">
<h5 className="text-base">Project API Keys</h5>
<p className="text-sm text-foreground-light">
@@ -159,9 +167,9 @@ const DisplayApiSettings = ({
descriptionText={
x.tags === 'service_role'
? 'This key has the ability to bypass Row Level Security. Never share it publicly. If leaked, generate a new JWT secret immediately. ' +
(legacy ? 'Prefer using Publishable API keys instead.' : '')
(showLegacyText ? 'Prefer using Publishable API keys instead.' : '')
: 'This key is safe to use in a browser if you have enabled Row Level Security for your tables and configured policies. ' +
(legacy ? 'Prefer using Secret API keys instead.' : '')
(showLegacyText ? 'Prefer using Secret API keys instead.' : '')
}
/>
@@ -201,7 +209,7 @@ const DisplayApiSettings = ({
)
) : null}
</Panel>
{newApiKeysInRollOut && !showNotice && <ToggleLegacyApiKeysPanel />}
</>
)
}
export default DisplayApiSettings
@@ -0,0 +1,130 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useToggleLegacyAPIKeysMutation } from 'data/api-keys/legacy-api-key-toggle-mutation'
import { useLegacyAPIKeysStatusQuery } from 'data/api-keys/legacy-api-keys-status-query'
import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions'
import { Alert_Shadcn_, AlertDescription_Shadcn_, AlertTitle_Shadcn_, CriticalIcon } from 'ui'
import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal'
export const ToggleLegacyApiKeysPanel = () => {
const { project } = useProjectContext()
const [isConfirmOpen, setIsConfirmOpen] = useState(false)
const { data: legacyAPIKeysStatusData, isSuccess: isLegacyAPIKeysStatusSuccess } =
useLegacyAPIKeysStatusQuery({ projectRef: project!.ref })
const { can: canUpdateAPIKeys, isSuccess: isPermissionsSuccess } =
useAsyncCheckProjectPermissions(PermissionAction.SECRETS_WRITE, '*')
if (!(isLegacyAPIKeysStatusSuccess && isPermissionsSuccess)) {
return null
}
return (
<section>
<Alert_Shadcn_ variant={legacyAPIKeysStatusData.enabled ? 'destructive' : 'warning'}>
<CriticalIcon />
<AlertTitle_Shadcn_>
{legacyAPIKeysStatusData.enabled
? 'Disabling your legacy API keys may cause your applications to break.'
: 'Re-enabling your legacy API keys may expose your applications to security risks.'}
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
{legacyAPIKeysStatusData.enabled
? 'Make sure you are no longer using your legacy API keys before proceeding.'
: "Make sure you've tested your RLS policies."}
</AlertDescription_Shadcn_>
<div className="mt-2">
<ButtonTooltip
type={legacyAPIKeysStatusData.enabled ? 'danger' : 'warning'}
onClick={() => setIsConfirmOpen(true)}
disabled={!canUpdateAPIKeys}
tooltip={{
content: {
side: 'bottom',
text: !canUpdateAPIKeys
? 'You need additional permissions to enable or disable JWT-based API keys'
: undefined,
},
}}
>
{legacyAPIKeysStatusData.enabled
? 'Disable JWT-based API keys'
: 'Re-enable JWT-based API keys'}
</ButtonTooltip>
</div>
</Alert_Shadcn_>
<ToggleApiKeysModal
visible={isConfirmOpen}
onClose={() => setIsConfirmOpen(false)}
legacyAPIKeysStatusData={legacyAPIKeysStatusData}
/>
</section>
)
}
const ToggleApiKeysModal = ({
visible,
onClose,
legacyAPIKeysStatusData,
}: {
visible: boolean
onClose: () => void
legacyAPIKeysStatusData: { enabled: boolean }
}) => {
const { project } = useProjectContext()
const { mutate: toggleLegacyAPIKey, isLoading: isTogglingLegacyAPIKey } =
useToggleLegacyAPIKeysMutation()
const onToggleLegacyAPIKeysEnabled = () => {
const enabled = !legacyAPIKeysStatusData?.enabled
toggleLegacyAPIKey(
{ projectRef: project!.ref, enabled },
{
onSuccess: () => {
toast.success(
enabled
? 'Your anon and service_role keys have been re-enabled!'
: 'Your anon and service_role keys have been disabled!'
)
onClose()
},
}
)
}
return (
<TextConfirmModal
visible={visible}
onCancel={() => onClose()}
onConfirm={onToggleLegacyAPIKeysEnabled}
title={
legacyAPIKeysStatusData.enabled ? 'Disable JWT-based keys' : 'Re-enable JWT-based keys'
}
confirmString={legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'}
confirmLabel={`Yes, ${legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'} anon and service_role`}
confirmPlaceholder={legacyAPIKeysStatusData.enabled ? 'disable' : 're-enable'}
loading={isTogglingLegacyAPIKey}
variant={legacyAPIKeysStatusData.enabled ? 'destructive' : 'default'}
alert={
legacyAPIKeysStatusData.enabled
? {
title: 'Disabling can cause downtime!',
description: `If you disable your anon and service_role keys while they are in use, your applications will stop functioning. All API endpoints will receive HTTP 401 Unauthorized. Make sure you are no longer using them before proceeding.`,
}
: {
title: 'Prefer publishable and secret keys',
description:
'While re-enabling anon and service_role keys makes sense in some cases, a better and more secure alternative is the publishable or secret key. Consider using those before proceeding!',
}
}
/>
)
}
@@ -1,4 +1,4 @@
import DisplayApiSettings from './DisplayApiSettings'
import { DisplayApiSettings } from './DisplayApiSettings'
import DisplayConfigSettings from './DisplayConfigSettings'
export { DisplayApiSettings, DisplayConfigSettings }
+2 -2
View File
@@ -43,12 +43,12 @@ type PublishableKeys = {
updated_at?: string
}
export interface APIKeysVariables {
interface APIKeysVariables {
projectRef?: string
reveal: boolean
}
export async function getAPIKeys({ projectRef, reveal }: APIKeysVariables, signal?: AbortSignal) {
async function getAPIKeys({ projectRef, reveal }: APIKeysVariables, signal?: AbortSignal) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await get(`/v1/projects/{ref}/api-keys`, {
+1
View File
@@ -1,4 +1,5 @@
export const apiKeysKeys = {
list: (projectRef?: string) => ['projects', projectRef, 'api-keys'] as const,
single: (projectRef?: string, id?: string) => ['projects', projectRef, 'api-keys', id] as const,
status: (projectRef?: string) => ['projects', projectRef, 'api-keys', 'legacy'] as const,
}
@@ -0,0 +1,60 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { handleError, put } from 'data/fetchers'
import { toast } from 'sonner'
import type { ResponseError } from 'types'
import { apiKeysKeys } from './keys'
export type ToggleLegacyAPIKeysVariables = {
projectRef?: string
enabled: boolean
}
export async function toggleLegacyAPIKeys(payload: ToggleLegacyAPIKeysVariables) {
if (!payload.projectRef) throw new Error('projectRef is required')
const { data, error } = await put('/v1/projects/{ref}/api-keys/legacy', {
params: {
path: { ref: payload.projectRef },
query: { enabled: payload.enabled },
},
})
if (error) handleError(error)
return data
}
type ToggleLegacyAPIKeysData = Awaited<ReturnType<typeof toggleLegacyAPIKeys>>
export const useToggleLegacyAPIKeysMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<ToggleLegacyAPIKeysData, ResponseError, ToggleLegacyAPIKeysVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<ToggleLegacyAPIKeysData, ResponseError, ToggleLegacyAPIKeysVariables>(
(vars) => toggleLegacyAPIKeys(vars),
{
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(apiKeysKeys.status(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(
`Failed to ${variables.enabled ? 're-enable' : 'disable'} JWT-based API keys: ${data.message}`
)
} else {
onError(data, variables, context)
}
},
...options,
}
)
}
@@ -0,0 +1,41 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { get, handleError } from 'data/fetchers'
import { ResponseError } from 'types'
import { apiKeysKeys } from './keys'
interface LegacyAPIKeysStatusVariables {
projectRef?: string
}
async function getLegacyAPIKeysStatus(
{ projectRef }: LegacyAPIKeysStatusVariables,
signal?: AbortSignal
) {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await get(`/v1/projects/{ref}/api-keys/legacy`, {
params: { path: { ref: projectRef } },
signal,
})
if (error) {
handleError(error)
}
return data
}
type LegacyAPIKeysStatusData = Awaited<ReturnType<typeof getLegacyAPIKeysStatus>>
export const useLegacyAPIKeysStatusQuery = <TData = LegacyAPIKeysStatusData>(
{ projectRef }: LegacyAPIKeysStatusVariables,
{ ...options }: UseQueryOptions<LegacyAPIKeysStatusData, ResponseError, TData> = {}
) =>
useQuery<LegacyAPIKeysStatusData, ResponseError, TData>(
apiKeysKeys.status(projectRef),
({ signal }) => getLegacyAPIKeysStatus({ projectRef }, signal),
{
enabled: !!projectRef,
...options,
}
)
@@ -5,7 +5,7 @@ import { DisplayApiSettings } from 'components/ui/ProjectSettings'
import type { NextPageWithLayout } from 'types'
const ApiKeysLegacyPage: NextPageWithLayout = () => {
return <DisplayApiSettings legacy showNotice={false} />
return <DisplayApiSettings showTitle={false} showNotice={false} />
}
ApiKeysLegacyPage.getLayout = (page) => (
+88 -1
View File
@@ -395,6 +395,24 @@ export interface paths {
patch: operations['updateApiKey']
trace?: never
}
'/v1/projects/{ref}/api-keys/legacy': {
parameters: {
query?: never
header?: never
path?: never
cookie?: never
}
/** [Beta] Check whether JWT based legacy (anon, service_role) API keys are enabled. This API endpoint will be removed in the future, check for HTTP 404 Not Found. */
get: operations['checkLegacyApiKeys']
/** [Beta] Disable or re-enable JWT based legacy (anon, service_role) API keys. This API endpoint will be removed in the future, check for HTTP 404 Not Found. */
put: operations['updateLegacyApiKeys']
post?: never
delete?: never
options?: never
head?: never
patch?: never
trace?: never
}
'/v1/projects/{ref}/billing/addons': {
parameters: {
query?: never
@@ -2062,6 +2080,9 @@ export interface components {
}
updated_at?: string
}
LegacyApiKeysResponse: {
enabled: boolean
}
ListProjectAddonsResponse: {
available_addons: {
name: string
@@ -2967,7 +2988,11 @@ export interface components {
allowed_release_channels: ('internal' | 'alpha' | 'beta' | 'ga' | 'withdrawn' | 'preview')[]
id: string
name: string
opt_in_tags: 'AI_SQL_GENERATOR_OPT_IN'[]
opt_in_tags: (
| 'AI_SQL_GENERATOR_OPT_IN'
| 'AI_DATA_GENERATOR_OPT_IN'
| 'AI_LOG_GENERATOR_OPT_IN'
)[]
/** @enum {string} */
plan?: 'free' | 'pro' | 'team' | 'enterprise'
}
@@ -3955,8 +3980,11 @@ export interface operations {
deleteApiKey: {
parameters: {
query?: {
reason?: string
/** @description Boolean string, true or false */
reveal?: boolean
/** @description Boolean string, true or false */
was_compromised?: boolean
}
header?: never
path: {
@@ -4020,6 +4048,65 @@ export interface operations {
}
}
}
checkLegacyApiKeys: {
parameters: {
query?: never
header?: never
path: {
/** @description Project ref */
ref: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['LegacyApiKeysResponse']
}
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
updateLegacyApiKeys: {
parameters: {
query: {
/** @description Boolean string, true or false */
enabled: boolean
}
header?: never
path: {
/** @description Project ref */
ref: string
}
cookie?: never
}
requestBody?: never
responses: {
200: {
headers: {
[name: string]: unknown
}
content: {
'application/json': components['schemas']['LegacyApiKeysResponse']
}
}
403: {
headers: {
[name: string]: unknown
}
content?: never
}
}
}
'v1-list-project-addons': {
parameters: {
query?: never
+11 -4
View File
@@ -4251,7 +4251,7 @@ export interface components {
/** @default 0 */
recoveryTimeTarget?: number
}
ConfirmSubscriptionChangeBody: {
ConfirmCreateSubscriptionChangeBody: {
kind?: string
name: string
payment_intent_id: string
@@ -4554,6 +4554,7 @@ export interface components {
is_owner: boolean
name: string
opt_in_tags: string[]
organization_requires_mfa: boolean
plan: {
/** @enum {string} */
id: 'free' | 'pro' | 'team' | 'enterprise'
@@ -6151,6 +6152,7 @@ export interface components {
is_owner: boolean
name: string
opt_in_tags: string[]
organization_requires_mfa: boolean
plan: {
/** @enum {string} */
id: 'free' | 'pro' | 'team' | 'enterprise'
@@ -7274,8 +7276,9 @@ export interface components {
}[]
}
RunQueryBody: {
query: string
/** @default false */
disable_statement_timeout?: boolean
query: string
}
SearchProfileBody: {
keywords: string
@@ -8032,7 +8035,11 @@ export interface components {
/** Format: email */
billing_email?: string
name?: string
opt_in_tags?: 'AI_SQL_GENERATOR_OPT_IN'[]
opt_in_tags?: (
| 'AI_SQL_GENERATOR_OPT_IN'
| 'AI_DATA_GENERATOR_OPT_IN'
| 'AI_LOG_GENERATOR_OPT_IN'
)[]
}
UpdateOrganizationResponse: {
billing_email?: string
@@ -12047,7 +12054,7 @@ export interface operations {
}
requestBody: {
content: {
'application/json': components['schemas']['ConfirmSubscriptionChangeBody']
'application/json': components['schemas']['ConfirmCreateSubscriptionChangeBody']
}
}
responses: {