Add settings for queues: toggle expose through postgrest + permissions via table privileges (#30564)

* Add settings for queues: toggle expose through postgrest + permissions via table privileges

* Ensure appropriate grants are granted when toggling, and revoked when disabling

* Update to use queues_public schema

* Update queue schema to pgmq_public and add/remove from data api when enabling/disabling

* Fix query for retrieving toggle state

* Add schema invalidation

* Remove hard code

* Use QueuesSettings from Queues folder, remove from NewQueues

* Update SQL for toggling exposure + support RLS enabling

* Support toggling RLS for a queue

* Update admonition copy in queues for enabling/disable postgrest exposure

* Add custom RLS policy for queue

* Minor style fixes

* Fix

* Remove hard code

* Update RLS to add message regarding relevancy only if exposure to PostgREST is enabled

* Update message in exposing queues to postgREST

* Address feedback

* Address feedback

* Don't revoke postgres role stuff

* Remove hard code

* Update copy

* Update

* Address Oli's feedback, ensure that queues ALL have RLS enabled prior to allowing exposure to PostgREST

* Address remaining feedback

* Remove hardcode

* Update

* Address feedback
This commit is contained in:
Joshen Lim authored and GitHub committed 2024-11-27 12:10:33 +08:00
1 parent 48c0578ff7
commit d8a57c1c7e
44 files changed
+1471 -137

No files matched your search

@@ -10,6 +10,7 @@ import CopyButton from 'components/ui/CopyButton'
import NoSearchResults from 'components/ui/NoSearchResults'
import {
getGeneralPolicyTemplates,
getQueuePolicyTemplates,
getRealtimePolicyTemplates,
} from '../PolicyEditorModal/PolicyEditorModal.constants'
@@ -33,7 +34,9 @@ export const PolicyTemplates = ({
const templates =
schema === 'realtime'
? getRealtimePolicyTemplates()
: getGeneralPolicyTemplates(schema, table.length > 0 ? table : 'table_name')
: schema === 'pgmq'
? getQueuePolicyTemplates()
: getGeneralPolicyTemplates(schema, table.length > 0 ? table : 'table_name')
const baseTemplates =
selectedPolicy !== undefined
@@ -326,3 +326,21 @@ with check ( realtime.messages.extension = 'presence' AND realtime.topic() = 'ch
] 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: 'true',
check: 'true',
command: 'ALL',
roles: ['anon', 'authenticated'],
},
]
}
@@ -62,37 +62,43 @@ const PolicyRow = ({
'w-full last:border-0 space-x-4 border-b py-4 lg:items-center'
)}
>
<div className="flex grow flex-col space-y-1">
<div className="flex items-center space-x-4">
<p className="font-mono text-xs text-foreground-light">{policy.command}</p>
<p className="text-sm text-foreground">{policy.name}</p>
<div className="flex grow flex-col gap-y-1">
<div className="flex items-start gap-x-4">
<p className="font-mono text-xs text-foreground-light translate-y-[2px] min-w-12">
{policy.command}
</p>
<div className="flex flex-col gap-y-1">
<p className="text-sm text-foreground">{policy.name}</p>
<div className="flex items-center gap-x-1">
<div className="text-foreground-lighter text-sm">
Applied to:
{policy.roles.slice(0, 3).map((role, i) => (
<code key={`policy-${role}-${i}`} className="text-foreground-light text-xs">
{role}
</code>
))}{' '}
role
</div>
{policy.roles.length > 3 && (
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<code key="policy-etc" className="text-foreground-light text-xs">
+ {policy.roles.length - 3} more roles
</code>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" align="center">
{policy.roles.slice(3).join(', ')}
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
)}
</div>
</div>
{appliesToAnonymousUsers ? (
<Badge color="yellow">Applies to anonymous users</Badge>
) : null}
</div>
<div className="flex items-center gap-x-1 ml-[60px]">
<div className="text-foreground-lighter text-sm">
Applied to:
{policy.roles.slice(0, 3).map((role, i) => (
<code key={`policy-${role}-${i}`} className="text-foreground-light text-xs">
{role}
</code>
))}{' '}
role
</div>
{policy.roles.length > 3 && (
<Tooltip_Shadcn_>
<TooltipTrigger_Shadcn_ asChild>
<code key="policy-etc" className="text-foreground-light text-xs">
+ {policy.roles.length - 3} more roles
</code>
</TooltipTrigger_Shadcn_>
<TooltipContent_Shadcn_ side="bottom" align="center">
{policy.roles.slice(3).join(', ')}
</TooltipContent_Shadcn_>
</Tooltip_Shadcn_>
)}
</div>
</div>
<div>
{!isLocked && (
@@ -13,7 +13,7 @@ import {
useEnumeratedTypesQuery,
} from 'data/enumerated-types/enumerated-types-query'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import {
Button,
DropdownMenu,
@@ -53,7 +53,7 @@ const EnumeratedTypes = () => {
: enumeratedTypes.filter((x) => x.schema === selectedSchema)
const protectedSchemas = (schemas ?? []).filter((schema) =>
EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
PROTECTED_SCHEMAS.includes(schema?.name ?? '')
)
const schema = schemas?.find((schema) => schema.name === selectedSchema)
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
@@ -13,7 +13,7 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex
import { useDatabaseFunctionCreateMutation } from 'data/database-functions/database-functions-create-mutation'
import { DatabaseFunction } from 'data/database-functions/database-functions-query'
import { useDatabaseFunctionUpdateMutation } from 'data/database-functions/database-functions-update-mutation'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import type { FormSchema } from 'types'
import {
Button,
@@ -203,7 +203,7 @@ const CreateFunction = ({ func, visible, setVisible }: CreateFunctionProps) => {
<FormControl_Shadcn_>
<SchemaSelector
selectedSchemaName={field.value}
excludedSchemas={EXCLUDED_SCHEMAS}
excludedSchemas={PROTECTED_SCHEMAS}
size="small"
onSelectSchema={(name) => field.onChange(name)}
/>
@@ -17,7 +17,7 @@ import { useDatabaseFunctionsQuery } from 'data/database-functions/database-func
import { useSchemasQuery } from 'data/database/schemas-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { useAppStateSnapshot } from 'state/app-state'
import { AiIconAnimation, Input } from 'ui'
import ProtectedSchemaWarning from '../../ProtectedSchemaWarning'
@@ -63,7 +63,7 @@ const FunctionsList = ({
connectionString: project?.connectionString,
})
const [protectedSchemas] = partition(schemas ?? [], (schema) =>
EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
PROTECTED_SCHEMAS.includes(schema?.name ?? '')
)
const foundSchema = schemas?.find((schema) => schema.name === selectedSchema)
const isLocked = protectedSchemas.some((s) => s.id === foundSchema?.id)
@@ -13,7 +13,7 @@ import { DatabaseIndex, useIndexesQuery } from 'data/database/indexes-query'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useExecuteSqlMutation } from 'data/sql/execute-sql-mutation'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { AlertCircle, Search, Trash } from 'lucide-react'
import { Button, Input, SidePanel } from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
@@ -64,7 +64,7 @@ const Indexes = () => {
})
const [protectedSchemas] = partition(schemas ?? [], (schema) =>
EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
PROTECTED_SCHEMAS.includes(schema?.name ?? '')
)
const schema = schemas?.find((schema) => schema.name === selectedSchema)
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
@@ -1,7 +1,7 @@
import { useState } from 'react'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button, Modal } from 'ui'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { AlertCircle } from 'lucide-react'
export const ProtectedSchemaModal = ({
@@ -31,7 +31,7 @@ export const ProtectedSchemaModal = ({
access through the dashboard.
</p>
<div className="flex flex-wrap gap-1">
{EXCLUDED_SCHEMAS.map((schema) => (
{PROTECTED_SCHEMAS.map((schema) => (
<code key={schema} className="text-xs">
{schema}
</code>
@@ -10,7 +10,7 @@ import InformationBox from 'components/ui/InformationBox'
import { Loading } from 'components/ui/Loading'
import { useTablesQuery } from 'data/tables/tables-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { Button, Input } from 'ui'
import PublicationsTableItem from './PublicationsTableItem'
import { ChevronLeft, Search, AlertCircle } from 'lucide-react'
@@ -44,8 +44,8 @@ const PublicationsTables = ({ selectedPublication, onSelectBack }: PublicationsT
select(tables) {
return tables.filter((table) =>
filterString.length === 0
? !EXCLUDED_SCHEMAS.includes(table.schema)
: !EXCLUDED_SCHEMAS.includes(table.schema) && table.name.includes(filterString)
? !PROTECTED_SCHEMAS.includes(table.schema)
: !PROTECTED_SCHEMAS.includes(table.schema) && table.name.includes(filterString)
)
},
}
@@ -15,7 +15,7 @@ import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { isTableLike } from 'data/table-editor/table-editor-types'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import {
Button,
DropdownMenu,
@@ -61,7 +61,7 @@ const ColumnList = ({
? selectedTable?.columns ?? []
: selectedTable?.columns?.filter((column: any) => column.name.includes(filterString))) ?? []
const isLocked = EXCLUDED_SCHEMAS.includes(selectedTable?.schema ?? '')
const isLocked = PROTECTED_SCHEMAS.includes(selectedTable?.schema ?? '')
const canUpdateColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns')
return (
@@ -36,7 +36,7 @@ import { useTablesQuery } from 'data/tables/tables-query'
import { useViewsQuery } from 'data/views/views-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import {
Button,
Checkbox_Shadcn_,
@@ -185,7 +185,7 @@ const TableList = ({
(x) => visibleTypes.includes(x.type)
)
const isLocked = EXCLUDED_SCHEMAS.includes(selectedSchema)
const isLocked = PROTECTED_SCHEMAS.includes(selectedSchema)
const error = tablesError || viewsError || materializedViewsError || foreignTablesError
const isError = isErrorTables || isErrorViews || isErrorMaterializedViews || isErrorForeignTables
@@ -19,7 +19,7 @@ import { useDatabaseTriggerCreateMutation } from 'data/database-triggers/databas
import { useDatabaseTriggerUpdateMutation } from 'data/database-triggers/database-trigger-update-mutation'
import { useTablesQuery } from 'data/tables/tables-query'
import { BASE_PATH } from 'lib/constants'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { PauseCircle, PlayCircle, Terminal } from 'lucide-react'
import type { Dictionary } from 'types'
import ChooseFunctionForm from './ChooseFunctionForm'
@@ -148,7 +148,7 @@ class CreateTriggerStore implements ICreateTriggerStore {
setTables = (value: any[]) => {
this.tables = value
.sort((a, b) => a.schema.localeCompare(b.schema))
.filter((a) => !EXCLUDED_SCHEMAS.includes(a.schema)) as any
.filter((a) => !PROTECTED_SCHEMAS.includes(a.schema)) as any
this.setDefaultSelectedTable()
}
@@ -1,25 +1,26 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { noop, partition } from 'lodash'
import { Search } from 'lucide-react'
import { useState } from 'react'
import { Input, AiIconAnimation } from 'ui'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AlphaPreview from 'components/to-be-cleaned/AlphaPreview'
import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState'
import Table from 'components/to-be-cleaned/Table'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import SchemaSelector from 'components/ui/SchemaSelector'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useDatabaseTriggersQuery } from 'data/database-triggers/database-triggers-query'
import { useSchemasQuery } from 'data/database/schemas-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { Search } from 'lucide-react'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { useAppStateSnapshot } from 'state/app-state'
import { AiIconAnimation, Input } from 'ui'
import ProtectedSchemaWarning from '../../ProtectedSchemaWarning'
import TriggerList from './TriggerList'
import { useAppStateSnapshot } from 'state/app-state'
import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
interface TriggersListProps {
createTrigger: () => void
@@ -41,7 +42,7 @@ const TriggersList = ({
connectionString: project?.connectionString,
})
const [protectedSchemas] = partition(schemas ?? [], (schema) =>
EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
PROTECTED_SCHEMAS.includes(schema?.name ?? '')
)
const schema = schemas?.find((schema) => schema.name === selectedSchema)
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
@@ -140,7 +140,7 @@ export function DiskManagementForm() {
/**
* Handle default values
*/
// @ts-ignore [Joshen TODO] check whats happening here
// @ts-ignore
const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 }
const defaultValues = {
storageType: type ?? DiskType.GP3,
@@ -118,7 +118,7 @@ export function DiskManagementPanelForm() {
},
}
)
// @ts-ignore [Joshen TODO] check whats happening here
// @ts-ignore
const { type, iops, throughput_mbps, size_gb } = data?.attributes ?? { size_gb: 0 }
const isRequestingChanges = data?.requested_modification !== undefined
@@ -76,6 +76,10 @@ const supabaseIntegrations: IntegrationDefinition[] = [
<Layers size={12} strokeWidth={1.5} className={cn('text-foreground w-full h-full')} />
),
},
{
route: 'settings',
label: 'Settings',
},
],
navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => {
if (childId) {
@@ -87,17 +91,20 @@ const supabaseIntegrations: IntegrationDefinition[] = [
case 'overview':
return dynamic(
() =>
import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then(
(mod) => mod.IntegrationOverviewTab
import('components/interfaces/Integrations/Queues/OverviewTab').then(
(mod) => mod.QueuesOverviewTab
),
{
loading: Loading,
}
{ loading: Loading }
)
case 'queues':
return dynamic(() => import('../Queues/QueuesTab').then((mod) => mod.QueuesTab), {
loading: Loading,
})
case 'settings':
return dynamic(
() => import('../Queues/QueuesSettings').then((mod) => mod.QueuesSettings),
{ loading: Loading }
)
}
return null
},
@@ -3,12 +3,15 @@ import { SubmitHandler, useForm } from 'react-hook-form'
import { toast } from 'sonner'
import z from 'zod'
import { Markdown } from 'components/interfaces/Markdown'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query'
import { useDatabaseQueueCreateMutation } from 'data/database-queues/database-queues-create-mutation'
import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query'
import {
Badge,
Button,
Checkbox_Shadcn_,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
@@ -22,9 +25,11 @@ import {
SheetSection,
SheetTitle,
} from 'ui'
import { Admonition } from 'ui-patterns'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { QUEUE_TYPES } from './Queues.constants'
import { useRouter } from 'next/router'
export interface CreateQueueSheetProps {
isClosing: boolean
@@ -52,6 +57,7 @@ const FormSchema = z.object({
.trim()
.min(1, 'Please provide a name for your queue')
.max(47, "The name can't be longer than 47 characters"),
enableRls: z.boolean(),
values: z.discriminatedUnion('type', [
normalQueueSchema,
partitionedQueueSchema,
@@ -67,30 +73,33 @@ const FORM_ID = 'create-queue-sidepanel'
export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQueueSheetProps) => {
// This is for enabling pg_partman extension which will be used for partitioned queues (3rd kind of queue)
// const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false)
const { mutate: createQueue, isLoading } = useDatabaseQueueCreateMutation()
// const canToggleExtensions = useCheckPermissions(
// PermissionAction.TENANT_SQL_ADMIN_WRITE,
// 'extensions'
// )
const router = useRouter()
const { project } = useProjectContext()
const { data: isExposed } = useQueuesExposePostgrestStatusQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { mutate: createQueue, isLoading } = useDatabaseQueueCreateMutation()
const form = useForm<CreateQueueForm>({
resolver: zodResolver(FormSchema),
defaultValues: {
name: '',
values: {
type: 'basic',
},
enableRls: true,
values: { type: 'basic' },
},
})
const { project } = useProjectContext()
const isEdited = form.formState.isDirty
// if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet
if (!isEdited && isClosing) {
onClose()
}
if (!isEdited && isClosing) onClose()
const onClosePanel = () => {
if (isEdited) {
@@ -100,24 +109,26 @@ export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQue
}
}
const onSubmit: SubmitHandler<CreateQueueForm> = async ({ name, values }) => {
let query = `SELECT pgmq.create('${name}');`
if (values.type === 'partitioned') {
query = `select from pgmq.create_partitioned('${name}', '${values.partitionInterval}', '${values.retentionInterval}');`
}
if (values.type === 'unlogged') {
query = `SELECT pgmq.create_unlogged('${name}');`
}
const onSubmit: SubmitHandler<CreateQueueForm> = async ({ name, enableRls, values }) => {
createQueue(
{
projectRef: project!.ref,
connectionString: project?.connectionString,
query,
name,
enableRls,
type: values.type,
configuration:
values.type === 'partitioned'
? {
partitionInterval: values.partitionInterval,
retentionInterval: values.retentionInterval,
}
: undefined,
},
{
onSuccess: () => {
toast.success(`Successfully created queue ${name}`)
router.push(`/project/${project?.ref}/integrations/queues/queues/${name}`)
onClose()
},
}
@@ -187,22 +198,20 @@ export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQue
}
showIndicator={false}
>
<div className="flex items-center gap-x-5">
<div className="flex items-start gap-x-5">
<div className="text-foreground">{definition.icon}</div>
<div className="flex flex-col">
<div className="flex gap-x-2">
<p className="text-foreground">{definition.label}</p>
<div className="flex flex-col gap-y-1">
<div className="flex items-center gap-x-2">
<p className="text-foreground text-left">{definition.label}</p>
{definition.value === 'partitioned' && (
<Badge variant="warning">Coming soon</Badge>
)}
</div>
<p className="text-foreground-light text-left">
<p className="text-foreground-lighter text-left">
{definition.description}
</p>
</div>
</div>
{definition.value === 'partitioned' ? (
<div className="pt-2 pl-10">
<Badge variant="warning">COMING SOON</Badge>
</div>
) : null}
{/* {!pgPartmanExtensionInstalled &&
definition.value === 'partitioned' ? (
<div className="w-full flex gap-x-2 pl-11 py-2 items-center">
@@ -287,8 +296,53 @@ export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQue
)}
/>
</SheetSection>
<Separator />
</>
)}
<SheetSection className="flex flex-col gap-y-2">
<FormField_Shadcn_
control={form.control}
name="enableRls"
render={({ field }) => (
<FormItemLayout
layout="flex"
label={
<div className="flex items-center gap-x-2">
<p>Enable Row Level Security (RLS)</p>
<Badge color="scale">Recommended</Badge>
</div>
}
description="Restrict access to your queue by enabling RLS and writing Postgres policies to control access for each role."
>
<FormControl_Shadcn_>
<Checkbox_Shadcn_
checked={field.value}
onCheckedChange={field.onChange}
disabled={field.disabled || isExposed}
/>
</FormControl_Shadcn_>
</FormItemLayout>
)}
/>
{!isExposed ? (
<Admonition
type="default"
title="Row Level Security for queues is only relevant if exposure through PostgREST has been enabled"
>
<Markdown
className="[&>p]:!leading-normal"
content={`You may opt to manage your queues via any Supabase client libraries or PostgREST
endpoints by enabling this in the [queues settings](/project/${project?.ref}/integrations/queues/settings).`}
/>
</Admonition>
) : (
<Admonition
type="default"
title="RLS must be enabled as queues are exposed via PostgREST"
description="This is to prevent anonymous access to any of your queues"
/>
)}
</SheetSection>
</form>
</Form_Shadcn_>
</div>
@@ -0,0 +1,40 @@
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab'
import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query'
import { Admonition } from 'ui-patterns'
import { Button } from 'ui'
import Link from 'next/link'
import { useParams } from 'common'
export const QueuesOverviewTab = () => {
const { ref } = useParams()
const project = useSelectedProject()
const { data: isExposed } = useQueuesExposePostgrestStatusQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
return (
<IntegrationOverviewTab
actions={
!isExposed ? (
<Admonition
type="default"
title="Queues can be managed via any Supabase client library or PostgREST endpoints"
>
<p>
You may choose to toggle the exposure of Queues through PostgREST via the queues
settings
</p>
<Button asChild type="default">
<Link href={`/project/${ref}/integrations/queues/settings`}>
Head to queues settings
</Link>
</Button>
</Admonition>
) : null
}
/>
)
}
@@ -1,5 +1,7 @@
import { Paintbrush, Trash2 } from 'lucide-react'
import { Lock, Paintbrush, PlusCircle, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import DeleteQueue from 'components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue'
@@ -7,20 +9,60 @@ import PurgeQueue from 'components/interfaces/Integrations/Queues/SingleQueue/Pu
import { QUEUE_MESSAGE_TYPE } from 'components/interfaces/Integrations/Queues/SingleQueue/Queue.utils'
import { QueueMessagesDataGrid } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid'
import { QueueFilters } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueFilters'
import { QueueSettings } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueSettings'
import { SendMessageModal } from 'components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal'
import { Markdown } from 'components/interfaces/Markdown'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query'
import { useQueueMessagesInfiniteQuery } from 'data/database-queues/database-queue-messages-infinite-query'
import { Button, LoadingLine, Separator } from 'ui'
import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query'
import { useTableUpdateMutation } from 'data/tables/table-update-mutation'
import { useTablesQuery } from 'data/tables/tables-query'
import {
Button,
cn,
LoadingLine,
Popover_Shadcn_,
PopoverContent_Shadcn_,
PopoverTrigger_Shadcn_,
Separator,
} from 'ui'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
export const QueueTab = () => {
const { childId: queueName } = useParams()
const { childId: queueName, ref } = useParams()
const { project } = useProjectContext()
const [openRlsPopover, setOpenRlsPopover] = useState(false)
const [rlsConfirmModalOpen, setRlsConfirmModalOpen] = useState(false)
const [sendMessageModalShown, setSendMessageModalShown] = useState(false)
const [purgeQueueModalShown, setPurgeQueueModalShown] = useState(false)
const [deleteQueueModalShown, setDeleteQueueModalShown] = useState(false)
const [selectedTypes, setSelectedTypes] = useState<QUEUE_MESSAGE_TYPE[]>([])
const { data, isLoading, isError, fetchNextPage, isFetching } = useQueueMessagesInfiniteQuery(
const { data: tables, isLoading: isLoadingTables } = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: 'pgmq',
})
const queueTable = tables?.find((x) => x.name === `q_${queueName}`)
const isRlsEnabled = queueTable?.rls_enabled ?? false
const { data: policies } = useDatabasePoliciesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: 'pgmq',
})
const queuePolicies = (policies ?? []).filter((policy) => policy.table === `q_${queueName}`)
const { data: isExposed } = useQueuesExposePostgrestStatusQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const { data, error, isLoading, fetchNextPage, isFetching } = useQueueMessagesInfiniteQuery(
{
projectRef: project?.ref,
connectionString: project?.connectionString,
@@ -32,27 +74,161 @@ export const QueueTab = () => {
)
const messages = useMemo(() => data?.pages.flatMap((p) => p), [data?.pages])
if (isError) {
return null
const { mutate: updateTable, isLoading: isUpdatingTable } = useTableUpdateMutation({
onSettled: () => {
toast.success(`Successfully enabled RLS for ${queueName}`)
setRlsConfirmModalOpen(false)
},
})
const onToggleRLS = async () => {
if (!project) return console.error('Project is required')
if (!queueTable) return toast.error('Unable to toggle RLS: Queue table not found')
const payload = {
id: queueTable.id,
rls_enabled: true,
}
updateTable({
projectRef: project?.ref,
connectionString: project?.connectionString,
id: payload.id,
schema: 'pgmq',
payload: payload,
})
}
return (
<div className="h-full flex flex-col">
<div className="flex items-center justify-end gap-x-4 py-4 px-6 mb-0">
<div className="flex gap-x-2">
<Button
<QueueSettings />
<ButtonTooltip
type="text"
className="px-1.5"
onClick={() => setPurgeQueueModalShown(true)}
icon={<Paintbrush />}
title="Purge messages"
tooltip={{ content: { side: 'bottom', text: 'Purge messages' } }}
/>
<Button
<ButtonTooltip
type="text"
className="px-1.5"
onClick={() => setDeleteQueueModalShown(true)}
icon={<Trash2 />}
title="Delete queue"
tooltip={{ content: { side: 'bottom', text: 'Delete queue' } }}
/>
<Separator orientation="vertical" className="h-[26px]" />
{isLoadingTables ? (
<ShimmeringLoader className="w-[123px]" />
) : isRlsEnabled ? (
<>
{queuePolicies.length === 0 ? (
<ButtonTooltip
asChild
type="default"
className="group"
icon={<PlusCircle strokeWidth={1.5} className="text-foreground-muted" />}
tooltip={{
content: {
side: 'bottom',
className: 'w-[280px]',
text: 'RLS is enabled for this queue, but no policies are set. Queue will not be accessible.',
},
}}
>
<Link
passHref
href={`/project/${ref}/auth/policies?search=${queueTable?.id}&schema=pgmq`}
>
Add RLS policy
</Link>
</ButtonTooltip>
) : (
<Button
asChild
type="default"
className="group"
icon={
<div
className={cn(
'flex items-center justify-center rounded-full bg-border-stronger h-[16px]',
queuePolicies.length > 9 ? ' px-1' : 'w-[16px]'
)}
>
<span className="text-[11px] text-foreground font-mono text-center">
{queuePolicies.length}
</span>
</div>
}
>
<Link
passHref
href={`/project/${ref}/auth/policies?search=${queueTable?.id}&schema=pgmq`}
>
Auth {queuePolicies.length > 1 ? 'policies' : 'policy'}
</Link>
</Button>
)}
</>
) : (
<Popover_Shadcn_
modal={false}
open={openRlsPopover}
onOpenChange={() => setOpenRlsPopover(!openRlsPopover)}
>
<PopoverTrigger_Shadcn_ asChild>
<Button type={isExposed ? 'warning' : 'default'} icon={<Lock strokeWidth={1.5} />}>
RLS disabled
</Button>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="w-80 text-sm" align="end">
<h3 className="text-xs flex items-center gap-x-2">
<Lock size={14} /> Row Level Security (RLS)
</h3>
<div className="grid gap-2 mt-2 text-foreground-light text-xs">
{isExposed ? (
<>
<p>
You can restrict and control who can manage this queue using Row Level
Security.
</p>
<p>With RLS enabled, anonymous users will not have access to this queue.</p>
<Button
type="default"
className="w-min"
onClick={() => setRlsConfirmModalOpen(!rlsConfirmModalOpen)}
>
Enable RLS for this queue
</Button>
</>
) : (
<>
<Markdown
className="[&>p]:!leading-normal text-xs [&>p]:!m-0 flex flex-col gap-y-2"
content={`
RLS for queues is only relevant if exposure through PostgREST has been enabled, in which you can restrict and control who can manage this queue using Row Level Security.
You may opt to manage your queues via any Supabase client libraries or PostgREST endpoints by enabling this in the [queues settings](/project/${project?.ref}/integrations/queues/settings).`}
/>
<Button
type="default"
className="w-min"
onClick={() => setRlsConfirmModalOpen(!rlsConfirmModalOpen)}
>
Enable RLS for this queue
</Button>
</>
)}
</div>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)}
<Button type="primary" onClick={() => setSendMessageModalShown(true)}>
Add message
</Button>
@@ -64,6 +240,7 @@ export const QueueTab = () => {
<QueueFilters selectedTypes={selectedTypes} setSelectedTypes={setSelectedTypes} />
<LoadingLine loading={isFetching} />
<QueueMessagesDataGrid
error={error}
messages={messages || []}
isLoading={isLoading}
showMessageModal={() => setSendMessageModalShown(true)}
@@ -83,6 +260,20 @@ export const QueueTab = () => {
visible={purgeQueueModalShown}
onClose={() => setPurgeQueueModalShown(false)}
/>
<ConfirmationModal
visible={rlsConfirmModalOpen}
title="Confirm to enable Row Level Security"
confirmLabel="Enable RLS"
confirmLabelLoading="Enabling RLS"
loading={isUpdatingTable}
onCancel={() => setRlsConfirmModalOpen(false)}
onConfirm={() => onToggleRLS()}
>
<p className="text-sm text-foreground-light">
Are you sure you want to enable Row Level Security for the queue "{queueName}"?
</p>
</ConfirmationModal>
</div>
)
}
@@ -1,12 +1,13 @@
import dayjs from 'dayjs'
import { includes, sortBy } from 'lodash'
import { ChevronRight, Loader2 } from 'lucide-react'
import { Check, ChevronRight, Loader2, X } from 'lucide-react'
import { useRouter } from 'next/router'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import Table from 'components/to-be-cleaned/Table'
import { useQueuesMetricsQuery } from 'data/database-queues/database-queues-metrics-query'
import { PostgresQueue } from 'data/database-queues/database-queues-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { DATETIME_FORMAT } from 'lib/constants'
interface QueuesRowsProps {
@@ -18,6 +19,14 @@ const QueueRow = ({ queue }: { queue: PostgresQueue }) => {
const router = useRouter()
const { project: selectedProject } = useProjectContext()
const { data: queueTables } = useTablesQuery({
projectRef: selectedProject?.ref,
connectionString: selectedProject?.connectionString,
schema: 'pgmq',
})
const queueTable = queueTables?.find((x) => x.name === `q_${queue.queue_name}`)
const isRlsEnabled = !!queueTable?.rls_enabled
const { data: metrics, isLoading } = useQueuesMetricsQuery(
{
queueName: queue.queue_name,
@@ -48,6 +57,11 @@ const QueueRow = ({ queue }: { queue: PostgresQueue }) => {
{type}
</p>
</Table.td>
<Table.td className="table-cell">
<div className="flex justify-center">
{isRlsEnabled ? <Check size={14} className="text-brand" /> : <X size={14} />}
</div>
</Table.td>
<Table.td className="table-cell">
<p title={queue.created_at}>{dayjs(queue.created_at).format(DATETIME_FORMAT)}</p>
</Table.td>
@@ -0,0 +1,342 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import { z } from 'zod'
import { DocsButton } from 'components/ui/DocsButton'
import { FormHeader } from 'components/ui/Forms/FormHeader'
import {
FormPanelContainer,
FormPanelContent,
FormPanelFooter,
} from 'components/ui/Forms/FormPanel'
import { useQueuesExposePostgrestStatusQuery } from 'data/database-queues/database-queues-expose-postgrest-status-query'
import {
QUEUES_SCHEMA,
useDatabaseQueueToggleExposeMutation,
} from 'data/database-queues/database-queues-toggle-postgrest-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import {
Button,
Form_Shadcn_,
FormControl_Shadcn_,
FormField_Shadcn_,
FormItem_Shadcn_,
Switch,
} from 'ui'
import { Admonition } from 'ui-patterns'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { useProjectPostgrestConfigUpdateMutation } from 'data/config/project-postgrest-config-update-mutation'
import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query'
import { useTablesQuery } from 'data/tables/tables-query'
import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal'
import { useTableUpdateMutation } from 'data/tables/table-update-mutation'
// [Joshen] Not convinced with the UI and layout but getting the functionality out first
export const QueuesSettings = () => {
const project = useSelectedProject()
const canUpdatePostgrestConfig = useCheckPermissions(
PermissionAction.UPDATE,
'custom_config_postgrest'
)
const [isToggling, setIsToggling] = useState(false)
const [rlsConfirmModalOpen, setRlsConfirmModalOpen] = useState(false)
const [isUpdatingRls, setIsUpdatingRls] = useState(false)
const formSchema = z.object({ enable: z.boolean() })
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: 'onChange',
defaultValues: { enable: false },
})
const { formState } = form
const { enable } = form.watch()
const { data: queueTables } = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: 'pgmq',
})
const tablesWithoutRLS =
queueTables?.filter((x) => x.name.startsWith('q_') && !x.rls_enabled) ?? []
const { data: config, error: configError } = useProjectPostgrestConfigQuery({
projectRef: project?.ref,
})
const {
data: isExposed,
isSuccess,
isLoading,
} = useQueuesExposePostgrestStatusQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const schemas = config?.db_schema.replace(/ /g, '').split(',') ?? []
const { mutateAsync: updateTable } = useTableUpdateMutation()
const { mutate: updatePostgrestConfig } = useProjectPostgrestConfigUpdateMutation({
onSuccess: () => {
if (enable) {
toast.success('Queues can now be managed through client libraries or PostgREST endpoints!')
} else {
toast.success(
'Queues can no longer be managed through client libraries or PostgREST endpoints'
)
}
setIsToggling(false)
form.reset({ enable })
},
onError: (error) => {
setIsToggling(false)
toast.error(`Failed to toggle queue exposure via PostgREST: ${error.message}`)
},
})
const { mutate: toggleExposeQueuePostgrest } = useDatabaseQueueToggleExposeMutation({
onSuccess: (_, values) => {
if (project && config) {
if (values.enable) {
const updatedSchemas = schemas.concat([QUEUES_SCHEMA])
updatePostgrestConfig({
projectRef: project?.ref,
dbSchema: updatedSchemas.join(', '),
maxRows: config.max_rows,
dbExtraSearchPath: config.db_extra_search_path,
dbPool: config.db_pool,
})
} else {
const updatedSchemas = schemas.filter((x) => x !== QUEUES_SCHEMA)
updatePostgrestConfig({
projectRef: project?.ref,
dbSchema: updatedSchemas.join(', '),
maxRows: config.max_rows,
dbExtraSearchPath: config.db_extra_search_path,
dbPool: config.db_pool,
})
}
}
},
onError: (error) => {
setIsToggling(false)
toast.error(`Failed to toggle queue exposure via PostgREST: ${error.message}`)
},
})
const onToggleRLS = async () => {
if (!project) return console.error('Project is required')
setIsUpdatingRls(true)
try {
await Promise.all(
tablesWithoutRLS.map((x) =>
updateTable({
projectRef: project?.ref,
connectionString: project?.connectionString,
id: x.id,
schema: x.schema,
payload: { id: x.id, rls_enabled: true },
})
)
)
toast.success(
`Successfully enabled RLS on ${tablesWithoutRLS.length === 1 ? tablesWithoutRLS[0].name : `${tablesWithoutRLS.length} queue${tablesWithoutRLS.length > 1 ? 's' : ''}`} `
)
setRlsConfirmModalOpen(false)
} catch (error: any) {
setIsUpdatingRls(false)
toast.error(`Failed to enable RLS on queues: ${error.message}`)
}
}
const onSubmit = async (values: z.infer<typeof formSchema>) => {
if (!project) return console.error('Project is required')
if (configError) {
return toast.error(
`Failed to toggle queue exposure via PostgREST: Unable to retrieve PostgREST configuration (${configError.message})`
)
}
setIsToggling(true)
toggleExposeQueuePostgrest({
projectRef: project.ref,
connectionString: project.connectionString,
enable: values.enable,
})
}
useEffect(() => {
if (isSuccess) form.reset({ enable: isExposed })
}, [isSuccess])
return (
<>
<div className="w-full flex flex-col gap-y-4 p-10">
<FormHeader
className="mb-0"
title="Settings"
description="Manage your queues via any client library or PostgREST endpoints"
/>
<Form_Shadcn_ {...form}>
<form id="pgmq-postgrest" onSubmit={form.handleSubmit(onSubmit)}>
<FormPanelContainer>
<FormPanelContent className="px-8 py-8">
<FormField_Shadcn_
control={form.control}
name="enable"
render={({ field }) => (
<FormItem_Shadcn_ className="w-full">
<FormItemLayout
className="w-full"
layout="flex"
label="Expose Queues via PostgREST"
description={
<>
<p className="max-w-2xl">
When enabled, you will be able to use the following functions from the{' '}
<code className="text-xs">{QUEUES_SCHEMA}</code> schema to manage your
queues via any Supabase client library or PostgREST endpoints:
</p>
<p className="mt-2">
<code className="text-xs">queue_send</code>,{' '}
<code className="text-xs">queue_send_batch</code>,{' '}
<code className="text-xs">queue_read</code>,{' '}
<code className="text-xs">queue_pop</code>,
<code className="text-xs">queue_archive</code>, and
<code className="text-xs">queue_delete</code>
</p>
</>
}
>
<FormControl_Shadcn_>
<Switch
name="enable"
size="large"
disabled={
isLoading || tablesWithoutRLS.length > 0 || !canUpdatePostgrestConfig
}
checked={field.value}
onCheckedChange={(value) => field.onChange(value)}
/>
</FormControl_Shadcn_>
</FormItemLayout>
{tablesWithoutRLS.length > 0 && (
<Admonition
type="default"
title="Existing Queues must have RLS enabled first before exposing via PostgREST"
className="mt-2"
>
<p className="!m-0">
Please ensure that the following {tablesWithoutRLS.length} queue
{tablesWithoutRLS.length > 1 ? 's' : ''} have RLS enabled in order to
prevent anonymous access.
</p>
<ul className="list-disc pl-6">
{tablesWithoutRLS.map((x) => {
return (
<li key={x.name}>
<code className="text-xs">{x.name.slice(2)}</code>
</li>
)
})}
</ul>
<Button
type="default"
className="mt-3"
onClick={() => setRlsConfirmModalOpen(true)}
>
Enable RLS on{' '}
{tablesWithoutRLS.length === 1
? tablesWithoutRLS[0].name.slice(2)
: `${tablesWithoutRLS.length} queues`}
</Button>
</Admonition>
)}
{formState.dirtyFields.enable && field.value === true && (
<Admonition type="warning" className="mt-2">
<p>
Queues will be exposed and managed through the{' '}
<code className="text-xs">{QUEUES_SCHEMA}</code> schema
</p>
<p className="text-foreground-light">
Database functions will be created in the{' '}
<code className="text-xs">{QUEUES_SCHEMA}</code> schema upon enabling.
Call these functions via any Supabase client library or PostgREST
endpoint to manage your queues. Permissions on individual queues can
also be further managed through privileges and row level security (RLS).
</p>
</Admonition>
)}
{formState.dirtyFields.enable && field.value === false && (
<Admonition type="warning" className="mt-2">
<p>
The <code className="text-xs">{QUEUES_SCHEMA}</code> schema will be
removed once disabled
</p>
<p className="text-foreground-light">
Ensure that the database functions from the{' '}
<code className="text-xs">{QUEUES_SCHEMA}</code> schema are not in use
within your client applications before disabling.
</p>
</Admonition>
)}
</FormItem_Shadcn_>
)}
/>
</FormPanelContent>
<FormPanelFooter className="flex px-8 py-4 flex items-center justify-between">
<DocsButton href="https://github.com/tembo-io/pgmq?tab=readme-ov-file#sql-examples" />
<div className="flex items-center gap-x-2">
<Button
type="default"
disabled={Object.keys(formState.dirtyFields).length === 0 || isToggling}
onClick={() => form.reset({ enable: false })}
>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
disabled={Object.keys(formState.dirtyFields).length === 0}
loading={isToggling}
>
Save changes
</Button>
</div>
</FormPanelFooter>
</FormPanelContainer>
</form>
</Form_Shadcn_>
</div>
<ConfirmationModal
visible={rlsConfirmModalOpen}
title="Confirm to enable Row Level Security"
confirmLabel="Enable RLS"
confirmLabelLoading="Enabling RLS"
loading={isUpdatingRls}
onCancel={() => setRlsConfirmModalOpen(false)}
onConfirm={() => onToggleRLS()}
>
<p className="text-sm text-foreground-light">
Are you sure you want to enable Row Level Security for the following queues:
</p>
<ul className="list-disc pl-6">
{tablesWithoutRLS.map((x) => {
return (
<li key={x.id}>
<code className="text-xs">{x.name.slice(2)}</code>
</li>
)
})}
</ul>
</ConfirmationModal>
</>
)
}
@@ -78,6 +78,9 @@ export const QueuesTab = () => {
<Table.th key="arguments" className="table-cell">
Type
</Table.th>
<Table.th key="rls_enabled" className="table-cell">
<div className="flex justify-center">RLS enabled</div>
</Table.th>
<Table.th key="created_at" className="table-cell w-60">
Created at
</Table.th>
@@ -94,7 +97,7 @@ export const QueuesTab = () => {
</div>
<Sheet open={createQueueSheetShown} onOpenChange={() => setIsClosingCreateQueueSheet(true)}>
<SheetContent size="default" tabIndex={undefined}>
<SheetContent size="default" className="w-[35%]" tabIndex={undefined}>
<CreateQueueSheet
onClose={() => {
setIsClosingCreateQueueSheet(false)
@@ -18,7 +18,7 @@ const DeleteQueue = ({ queueName, visible, onClose }: DeleteQueueProps) => {
const { mutate: deleteDatabaseQueue, isLoading } = useDatabaseQueueDeleteMutation({
onSuccess: () => {
toast.success(`Successfully removed queue ${queueName}`)
router.push(`/project/${project?.ref}/integrations/queues`)
router.push(`/project/${project?.ref}/integrations/queues/queues`)
onClose()
},
})
@@ -9,8 +9,11 @@ import { PostgresQueueMessage } from 'data/database-queues/database-queue-messag
import { Badge, Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui'
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { DATE_FORMAT, MessageDetailsPanel } from './MessageDetailsPanel'
import { ResponseError } from 'types'
import AlertError from 'components/ui/AlertError'
interface QueueDataGridProps {
error?: ResponseError | null
isLoading: boolean
messages: PostgresQueueMessage[]
showMessageModal: () => void
@@ -122,6 +125,7 @@ const columns = messagesCols.map((col) => {
})
export const QueueMessagesDataGrid = ({
error,
isLoading,
messages,
showMessageModal,
@@ -182,6 +186,10 @@ export const QueueMessagesDataGrid = ({
<div className="absolute top-14 px-6 w-full">
<GenericSkeletonLoader />
</div>
) : !!error ? (
<div className="absolute top-16 px-6 flex flex-col items-center justify-center w-full gap-y-2">
<AlertError subject="Failed to retrieve queue messages" error={error} />
</div>
) : (
<div className="absolute top-28 px-6 flex flex-col items-center justify-center w-full gap-y-2">
<TextSearch className="text-foreground-muted" strokeWidth={1} />
@@ -0,0 +1,302 @@
import { isEqual } from 'lodash'
import { Settings } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import AlertError from 'components/ui/AlertError'
import { ButtonTooltip } from 'components/ui/ButtonTooltip'
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
import {
TablePrivilegesGrant,
useTablePrivilegesGrantMutation,
} from 'data/privileges/table-privileges-grant-mutation'
import { useTablePrivilegesQuery } from 'data/privileges/table-privileges-query'
import {
TablePrivilegesRevoke,
useTablePrivilegesRevokeMutation,
} from 'data/privileges/table-privileges-revoke-mutation'
import { useTablesQuery } from 'data/tables/tables-query'
import { useSelectedProject } from 'hooks/misc/useSelectedProject'
import {
Button,
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetSection,
SheetTitle,
SheetTrigger,
Switch,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from 'ui'
import ShimmeringLoader from 'ui-patterns/ShimmeringLoader'
const ACTIONS = ['select', 'insert', 'update', 'delete']
type Privileges = { select?: boolean; insert?: boolean; update?: boolean; delete?: boolean }
interface QueueSettingsProps {}
export const QueueSettings = ({}: QueueSettingsProps) => {
const { childId: name } = useParams()
const project = useSelectedProject()
const [open, setOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [privileges, setPrivileges] = useState<{ [key: string]: Privileges }>({})
const { data, error, isLoading, isSuccess, isError } = useDatabaseRolesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const roles = (data ?? []).sort((a, b) => a.name.localeCompare(b.name))
const { data: queueTables } = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: 'pgmq',
})
const queueTable = queueTables?.find((x) => x.name === `q_${name}`)
const archiveTable = queueTables?.find((x) => x.name === `a_${name}`)
const { data: allTablePrivileges, isSuccess: isSuccessPrivileges } = useTablePrivilegesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const queuePrivileges = allTablePrivileges?.find(
(x) => x.schema === 'pgmq' && x.name === `q_${name}`
)
const { mutateAsync: grantPrivilege } = useTablePrivilegesGrantMutation()
const { mutateAsync: revokePrivilege } = useTablePrivilegesRevokeMutation()
const onTogglePrivilege = (role: string, action: string, value: boolean) => {
const updatedPrivileges = { ...privileges, [role]: { ...privileges[role], [action]: value } }
setPrivileges(updatedPrivileges)
}
const onSaveConfiguration = async () => {
if (!project) return console.error('Project is required')
if (!queueTable) return console.error('Unable to find queue table')
if (!archiveTable) return console.error('Unable to find archive table')
setIsSaving(true)
const revoke: { role: string; action: string }[] = []
const grant: { role: string; action: string }[] = []
Object.entries(privileges).forEach(([role, p]) => {
const originalRolePrivileges = queuePrivileges?.privileges.filter((x) => x.grantee === role)
Object.entries(p).forEach(([action, value]) => {
const originalValue = !!originalRolePrivileges?.find(
(x) => x.privilege_type.toLowerCase() === action
)
if (value !== originalValue) {
if (value) grant.push({ role, action })
else revoke.push({ role, action })
}
})
})
const rolesBeingGrantedPerms = [...new Set(grant.map((x) => x.role))]
const rolesBeingRevokedPerms = [...new Set(revoke.map((x) => x.role))]
const rolesNoLongerHavingPerms = rolesBeingRevokedPerms.filter((x) => {
const existingPrivileges = queuePrivileges?.privileges
.filter((y) => x === y.grantee)
.map((y) => y.privilege_type)
const privilegesGettingRevoked = revoke
.filter((y) => y.role === x)
.map((y) => y.action.toUpperCase())
const privilegesGettingGranted = grant.filter((y) => y.role === x)
return (
privilegesGettingGranted.length === 0 &&
isEqual(existingPrivileges, privilegesGettingRevoked)
)
})
try {
await Promise.all([
...(revoke.length > 0
? [
revokePrivilege({
projectRef: project.ref,
connectionString: project.connectionString,
revokes: revoke.map((x) => ({
grantee: x.role,
privilege_type: x.action.toUpperCase(),
relation_id: queueTable.id,
})) as TablePrivilegesRevoke[],
}),
]
: []),
// Revoke select + insert on archive table only if role no longer has ANY perms on the queue table
...(rolesNoLongerHavingPerms.length > 0
? [
revokePrivilege({
projectRef: project.ref,
connectionString: project.connectionString,
revokes: [
...rolesNoLongerHavingPerms.map((x) => ({
grantee: x,
privilege_type: 'INSERT' as 'INSERT',
relation_id: archiveTable.id,
})),
...rolesNoLongerHavingPerms.map((x) => ({
grantee: x,
privilege_type: 'SELECT' as 'SELECT',
relation_id: archiveTable.id,
})),
],
}),
]
: []),
...(grant.length > 0
? [
grantPrivilege({
projectRef: project.ref,
connectionString: project.connectionString,
grants: grant.map((x) => ({
grantee: x.role,
privilege_type: x.action.toUpperCase(),
relation_id: queueTable.id,
})) as TablePrivilegesGrant[],
}),
// Just grant select + insert on archive table as long as we're granting any perms to the queue table for the role
grantPrivilege({
projectRef: project.ref,
connectionString: project.connectionString,
grants: [
...rolesBeingGrantedPerms.map((x) => ({
grantee: x,
privilege_type: 'INSERT' as 'INSERT',
relation_id: archiveTable.id,
})),
...rolesBeingGrantedPerms.map((x) => ({
grantee: x,
privilege_type: 'SELECT' as 'SELECT',
relation_id: archiveTable.id,
})),
],
}),
]
: []),
])
toast.success('Successfully updated permissions')
setOpen(false)
} catch (error: any) {
toast.error(`Failed to update permissions: ${error.message}`)
} finally {
setIsSaving(false)
}
}
useEffect(() => {
if (open && isSuccessPrivileges && queuePrivileges) {
const initialState = queuePrivileges.privileges.reduce((a, b) => {
return {
...a,
[b.grantee]: { ...(a as any)[b.grantee], [b.privilege_type.toLowerCase()]: true },
}
}, {})
setPrivileges(initialState)
}
}, [open, isSuccessPrivileges])
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<ButtonTooltip
type="text"
className="px-1.5"
icon={<Settings />}
title="Settings"
tooltip={{ content: { side: 'bottom', text: 'Queue settings' } }}
/>
</SheetTrigger>
<SheetContent size="lg" className="overflow-auto flex flex-col gap-y-0">
<SheetHeader>
<SheetTitle>Manage queue permissions on {name}</SheetTitle>
<SheetDescription>
Configure permissions for each role to grant access to the relevant actions on the queue
</SheetDescription>
</SheetHeader>
<SheetSection className="p-0 flex-grow">
<Table>
<TableHeader className="[&_th]:h-8">
<TableRow className="py-2">
<TableHead>Role</TableHead>
{ACTIONS.map((x) => (
<TableHead key={x} className="capitalize">
{x}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="[&_td]:py-2">
{isLoading && (
<>
<TableRow>
<TableCell colSpan={5}>
<ShimmeringLoader />
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={4}>
<ShimmeringLoader />
</TableCell>
</TableRow>
<TableRow>
<TableCell colSpan={3}>
<ShimmeringLoader />
</TableCell>
</TableRow>
</>
)}
{isError && (
<TableRow>
<TableCell colSpan={5}>
<AlertError subject="Failed to retrieve roles" error={error} />
</TableCell>
</TableRow>
)}
{isSuccess &&
(roles ?? []).map((role) => {
return (
<TableRow key={role.id}>
<TableCell>{role.name}</TableCell>
{ACTIONS.map((x) => (
<TableCell key={x}>
<Switch
checked={
(privileges[role.name] as Privileges)?.[x as keyof Privileges] ??
false
}
onCheckedChange={(value) => onTogglePrivilege(role.name, x, value)}
/>
</TableCell>
))}
</TableRow>
)
})}
</TableBody>
</Table>
</SheetSection>
<SheetFooter>
<Button type="default" disabled={isSaving} onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="primary" loading={isSaving} onClick={onSaveConfiguration}>
Save changes
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)
}
@@ -6,7 +6,7 @@ import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-que
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { useTableEditorStateSnapshot } from 'state/table-editor'
export interface EmptyStateProps {}
@@ -14,7 +14,7 @@ export interface EmptyStateProps {}
const EmptyState = ({}: EmptyStateProps) => {
const snap = useTableEditorStateSnapshot()
const { selectedSchema } = useQuerySchemaState()
const isProtectedSchema = EXCLUDED_SCHEMAS.includes(selectedSchema)
const isProtectedSchema = PROTECTED_SCHEMAS.includes(selectedSchema)
const canCreateTables =
useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') && !isProtectedSchema
@@ -1,11 +1,10 @@
import type { PostgresTable } from '@supabase/postgres-meta'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import { Lock, MousePointer2, PlusCircle, Unlock } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'
import { useParams } from 'common'
import { useTrackedState } from 'components/grid/store/Store'
import { getEntityLintDetails } from 'components/interfaces/TableGridEditor/TableEntity.utils'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
@@ -25,7 +24,7 @@ import {
import { useTableUpdateMutation } from 'data/tables/table-update-mutation'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import {
Button,
PopoverContent_Shadcn_,
@@ -60,7 +59,7 @@ const GridHeaderActions = ({ table }: GridHeaderActionsProps) => {
const isMaterializedView = isTableLikeMaterializedView(table)
const realtimeEnabled = useIsFeatureEnabled('realtime:all')
const isLocked = EXCLUDED_SCHEMAS.includes(table.schema)
const isLocked = PROTECTED_SCHEMAS.includes(table.schema)
const { mutate: updateTable } = useTableUpdateMutation({
onError: (error) => {
@@ -17,7 +17,7 @@ import {
useForeignKeyConstraintsQuery,
} from 'data/database/foreign-key-constraints-query'
import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types-query'
import { EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas'
import type { Dictionary } from 'types'
import { Button, Checkbox, Input, SidePanel, Toggle } from 'ui'
import ActionBar from '../ActionBar'
@@ -84,7 +84,7 @@ const ColumnEditor = ({
connectionString: project?.connectionString,
})
const enumTypes = (types ?? []).filter(
(type) => !EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS.includes(type.schema)
(type) => !PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS.includes(type.schema)
)
const { data: constraints } = useTableConstraintsQuery({
@@ -21,7 +21,7 @@ import { useEnumeratedTypesQuery } from 'data/enumerated-types/enumerated-types-
import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { useUrlState } from 'hooks/ui/useUrlState'
import { EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS } from 'lib/constants/schemas'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import { Admonition } from 'ui-patterns'
import ActionBar from '../ActionBar'
@@ -97,7 +97,7 @@ const TableEditor = ({
connectionString: project?.connectionString,
})
const enumTypes = (types ?? []).filter(
(type) => !EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS.includes(type.schema)
(type) => !PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS.includes(type.schema)
)
const { data: publications } = useDatabasePublicationsQuery({
@@ -21,7 +21,7 @@ import { TableRowsData } from 'data/table-rows/table-rows-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import useLatest from 'hooks/misc/useLatest'
import { useUrlState } from 'hooks/ui/useUrlState'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { EMPTY_ARR } from 'lib/void'
import { useGetImpersonatedRole } from 'state/role-impersonation-state'
import { useTableEditorStateSnapshot } from 'state/table-editor'
@@ -125,7 +125,7 @@ const TableGridEditor = ({
const isViewSelected = isView(selectedTable) || isMaterializedView(selectedTable)
const isTableSelected = isTableLike(selectedTable)
const isLocked = EXCLUDED_SCHEMAS.includes(selectedTable?.schema ?? '')
const isLocked = PROTECTED_SCHEMAS.includes(selectedTable?.schema ?? '')
const canEditViaTableEditor = isTableSelected && !isLocked
const gridTable = parseSupaTable(selectedTable)
@@ -71,7 +71,7 @@ export const IntegrationTabs = ({ scroll, isSticky }: IntegrationTabsProps) => {
const tabUrl = `/project/${project?.ref}/integrations/${integration?.id}/${tab.route}`
return (
<div className="flex items-center gap-2" key={tab.route}>
<NavMenuItem active={pageId === tab.route}>
<NavMenuItem active={pageId === tab.route && !childId}>
<Link href={tabUrl}>{tab.label}</Link>
</NavMenuItem>
@@ -30,7 +30,6 @@ const ProjectIntegrationsMenu = () => {
const pgNetExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_net') !== undefined
const graphqlExtensionExists = (data ?? []).find((ext) => ext.name === 'pg_graphql') !== undefined
// TODO: Change this to true for local development to work
const pgmqExtensionExists = (data ?? []).find((ext) => ext.name === 'pgmq') !== undefined
return (
@@ -35,7 +35,7 @@ import {
useSetPage,
} from 'ui-patterns/CommandMenu'
import { usePrefetchTables, useTablesQuery, type TablesData } from 'data/tables/tables-query'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { useEffect, useRef } from 'react'
export function useSqlEditorGotoCommands(options?: CommandOptions) {
@@ -356,7 +356,7 @@ from ${formatTableIdentifier(table)}
}
function excludeSupabaseControlledSchemas(tables: TablesData) {
return tables.filter((table) => !EXCLUDED_SCHEMAS.includes(table.schema))
return tables.filter((table) => !PROTECTED_SCHEMAS.includes(table.schema))
}
// Not a perfectly spec-compliant regex , since Postgres also allows non-Latin
@@ -16,7 +16,7 @@ import { useTableEditorQuery } from 'data/table-editor/table-editor-query'
import { useCheckPermissions } from 'hooks/misc/useCheckPermissions'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { useTableEditorStateSnapshot } from 'state/table-editor'
import {
AlertDescription_Shadcn_,
@@ -93,7 +93,7 @@ const TableEditorMenu = () => {
const [protectedSchemas] = partition(
(schemas ?? []).sort((a, b) => a.name.localeCompare(b.name)),
(schema) => EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
(schema) => PROTECTED_SCHEMAS.includes(schema?.name ?? '')
)
const isLocked = protectedSchemas.some((s) => s.id === schema?.id)
@@ -2,6 +2,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { get } from 'lib/common/fetch'
import { API_URL } from 'lib/constants'
import { configKeys } from './keys'
import { ResponseError } from 'types'
export type ProjectPostgrestConfigVariables = {
projectRef?: string
@@ -36,7 +37,7 @@ export async function getProjectPostgrestConfig(
}
export type ProjectPostgrestConfigData = Awaited<ReturnType<typeof getProjectPostgrestConfig>>
export type ProjectPostgrestConfigError = unknown
export type ProjectPostgrestConfigError = ResponseError
export const useProjectPostgrestConfigQuery = <TData = ProjectPostgrestConfigData>(
{ projectRef }: ProjectPostgrestConfigVariables,
@@ -4,22 +4,41 @@ import { toast } from 'sonner'
import { executeSql } from 'data/sql/execute-sql-query'
import type { ResponseError } from 'types'
import { databaseQueuesKeys } from './keys'
import { tableKeys } from 'data/tables/keys'
export type DatabaseQueueCreateVariables = {
projectRef: string
connectionString?: string
query: string
name: string
type: 'basic' | 'partitioned' | 'unlogged'
enableRls: boolean
configuration?: {
partitionInterval?: number
retentionInterval?: number
}
}
export async function createDatabaseQueue({
projectRef,
connectionString,
query,
name,
type,
enableRls,
configuration,
}: DatabaseQueueCreateVariables) {
const { partitionInterval, retentionInterval } = configuration ?? {}
const query =
type === 'partitioned'
? `select from pgmq.create_partitioned('${name}', '${partitionInterval}', '${retentionInterval}');`
: type === 'unlogged'
? `SELECT pgmq.create_unlogged('${name}');`
: `SELECT pgmq.create('${name}');`
const { result } = await executeSql({
projectRef,
connectionString,
sql: query,
sql: `${query} ${enableRls ? `alter table pgmq."q_${name}" enable row level security;` : ''}`.trim(),
queryKey: databaseQueuesKeys.create(),
})
@@ -44,6 +63,7 @@ export const useDatabaseQueueCreateMutation = ({
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(databaseQueuesKeys.list(projectRef))
queryClient.invalidateQueries(tableKeys.list(projectRef, 'pgmq'))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -42,11 +42,8 @@ export const useDatabaseQueueDeleteMutation = ({
(vars) => deleteDatabaseQueue(vars),
{
async onSuccess(data, variables, context) {
const { projectRef, queueName } = variables
const { projectRef } = variables
await queryClient.invalidateQueries(databaseQueuesKeys.list(projectRef))
await queryClient.invalidateQueries(
databaseQueuesKeys.getMessagesInfinite(projectRef, queueName)
)
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
@@ -0,0 +1,47 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import minify from 'pg-minify'
import { executeSql } from 'data/sql/execute-sql-query'
import { ResponseError } from 'types'
import { QUEUES_SCHEMA } from './database-queues-toggle-postgrest-mutation'
import { databaseQueuesKeys } from './keys'
export type DatabaseQueuesVariables = {
projectRef?: string
connectionString?: string
}
// [Joshen] Check if all the relevant functions exist to indicate whether PGMQ has been exposed through PostgREST
const queueSqlQuery = minify(/**SQL */ `
SELECT exists (select schema_name FROM information_schema.schemata WHERE schema_name = '${QUEUES_SCHEMA}');
`)
export async function getDatabaseQueuesExposePostgrestStatus({
projectRef,
connectionString,
}: DatabaseQueuesVariables) {
if (!projectRef) throw new Error('Project ref is required')
const { result } = await executeSql({
projectRef,
connectionString,
sql: queueSqlQuery,
})
return result[0].exists as boolean
}
export type DatabaseQueueData = boolean
export type DatabaseQueueError = ResponseError
export const useQueuesExposePostgrestStatusQuery = <TData = DatabaseQueueData>(
{ projectRef, connectionString }: DatabaseQueuesVariables,
{ enabled = true, ...options }: UseQueryOptions<DatabaseQueueData, DatabaseQueueError, TData> = {}
) =>
useQuery<DatabaseQueueData, DatabaseQueueError, TData>(
databaseQueuesKeys.exposePostgrestStatus(projectRef),
() => getDatabaseQueuesExposePostgrestStatus({ projectRef, connectionString }),
{
enabled: enabled && typeof projectRef !== 'undefined',
...options,
}
)
@@ -0,0 +1,278 @@
import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import minify from 'pg-minify'
import { executeSql } from 'data/sql/execute-sql-query'
import type { ResponseError } from 'types'
import { databaseQueuesKeys } from './keys'
import { databaseKeys } from 'data/database/keys'
export type DatabaseQueueExposePostgrestVariables = {
projectRef: string
connectionString?: string
enable: boolean
}
export const QUEUES_SCHEMA = 'pgmq_public'
const EXPOSE_QUEUES_TO_POSTGREST_SQL = minify(/* SQL */ `
create schema if not exists ${QUEUES_SCHEMA};
grant usage on schema ${QUEUES_SCHEMA} to postgres, anon, authenticated, service_role;
create or replace function ${QUEUES_SCHEMA}.queue_pop(
queue_name text
)
returns setof pgmq.message_record
language plpgsql
set search_path = ''
as $$
begin
return query
select *
from pgmq.pop(
queue_name := queue_name
);
end;
$$;
comment on function ${QUEUES_SCHEMA}.queue_pop(queue_name text) is 'Retrieves and locks the next message from the specified queue.';
create or replace function ${QUEUES_SCHEMA}.queue_send(
queue_name text,
message jsonb,
sleep_seconds integer default 0 -- renamed from 'delay'
)
returns setof bigint
language plpgsql
set search_path = ''
as $$
begin
return query
select *
from pgmq.send(
queue_name := queue_name,
msg := message,
delay := sleep_seconds
);
end;
$$;
comment on function ${QUEUES_SCHEMA}.queue_send(queue_name text, message jsonb, sleep_seconds integer) is 'Sends a message to the specified queue, optionally delaying its availability by a number of seconds.';
create or replace function ${QUEUES_SCHEMA}.queue_send_batch(
queue_name text,
messages jsonb[],
sleep_seconds integer default 0 -- renamed from 'delay'
)
returns setof bigint
language plpgsql
set search_path = ''
as $$
begin
return query
select *
from pgmq.send_batch(
queue_name := queue_name,
msgs := messages,
delay := sleep_seconds
);
end;
$$;
comment on function ${QUEUES_SCHEMA}.queue_send_batch(queue_name text, messages jsonb[], sleep_seconds integer) is 'Sends a batch of messages to the specified queue, optionally delaying their availability by a number of seconds.';
create or replace function ${QUEUES_SCHEMA}.queue_archive(
queue_name text,
message_id bigint
)
returns boolean
language plpgsql
set search_path = ''
as $$
begin
return
pgmq.archive(
queue_name := queue_name,
msg_id := message_id
);
end;
$$;
comment on function ${QUEUES_SCHEMA}.queue_archive(queue_name text, message_id bigint) is 'Archives a message by moving it from the queue to a permanent archive.';
create or replace function ${QUEUES_SCHEMA}.queue_archive(
queue_name text,
message_id bigint
)
returns boolean
language plpgsql
set search_path = ''
as $$
begin
return
pgmq.archive(
queue_name := queue_name,
msg_id := message_id
);
end;
$$;
comment on function ${QUEUES_SCHEMA}.queue_archive(queue_name text, message_id bigint) is 'Archives a message by moving it from the queue to a permanent archive.';
create or replace function ${QUEUES_SCHEMA}.queue_delete(
queue_name text,
message_id bigint
)
returns boolean
language plpgsql
set search_path = ''
as $$
begin
return
pgmq.delete(
queue_name := queue_name,
msg_id := message_id
);
end;
$$;
comment on function ${QUEUES_SCHEMA}.queue_delete(queue_name text, message_id bigint) is 'Permanently deletes a message from the specified queue.';
create or replace function ${QUEUES_SCHEMA}.queue_read(
queue_name text,
sleep_seconds integer,
n integer
)
returns setof pgmq.message_record
language plpgsql
set search_path = ''
as $$
begin
return query
select *
from pgmq.read(
queue_name := queue_name,
vt := sleep_seconds,
qty := n
);
end;
$$;
comment on function ${QUEUES_SCHEMA}.queue_read(queue_name text, sleep_seconds integer, n integer) is 'Reads up to "n" messages from the specified queue with an optional "sleep_seconds" (visibility timeout).';
-- Grant execute permissions on wrapper functions to roles
grant execute on function ${QUEUES_SCHEMA}.queue_pop(text) to postgres, service_role, anon, authenticated;
grant execute on function pgmq.pop(text) to postgres, service_role, anon, authenticated;
grant execute on function ${QUEUES_SCHEMA}.queue_send(text, jsonb, integer) to postgres, service_role, anon, authenticated;
grant execute on function pgmq.send(text, jsonb, integer) to postgres, service_role, anon, authenticated;
grant execute on function ${QUEUES_SCHEMA}.queue_send_batch(text, jsonb[], integer) to postgres, service_role, anon, authenticated;
grant execute on function pgmq.send_batch(text, jsonb[], integer) to postgres, service_role, anon, authenticated;
grant execute on function ${QUEUES_SCHEMA}.queue_archive(text, bigint) to postgres, service_role, anon, authenticated;
grant execute on function pgmq.archive(text, bigint) to postgres, service_role, anon, authenticated;
grant execute on function ${QUEUES_SCHEMA}.queue_delete(text, bigint) to postgres, service_role, anon, authenticated;
grant execute on function pgmq.delete(text, bigint) to postgres, service_role, anon, authenticated;
grant execute on function ${QUEUES_SCHEMA}.queue_read(text, integer, integer) to postgres, service_role, anon, authenticated;
grant execute on function pgmq.read(text, integer, integer) to postgres, service_role, anon, authenticated;
-- For the service role, we want full access
-- Grant permissions on existing tables
grant all privileges on all tables in schema pgmq to postgres, service_role;
-- Ensure service_role has permissions on future tables
alter default privileges in schema pgmq grant all privileges on tables to postgres, service_role;
grant usage on schema pgmq to postgres, anon, authenticated, service_role;
`)
const HIDE_QUEUES_FROM_POSTGREST_SQL = minify(/* SQL */ `
drop function if exists
${QUEUES_SCHEMA}.queue_pop(queue_name text),
${QUEUES_SCHEMA}.queue_send(queue_name text, message jsonb, sleep_seconds integer),
${QUEUES_SCHEMA}.queue_send_batch(queue_name text, message jsonb[], sleep_seconds integer),
${QUEUES_SCHEMA}.queue_archive(queue_name text, message_id bigint),
${QUEUES_SCHEMA}.queue_delete(queue_name text, message_id bigint),
${QUEUES_SCHEMA}.queue_read(queue_name text, sleep integer, n integer)
;
-- Revoke execute permissions on inner pgmq functions to roles (inverse of enabling)
do $$
begin
if exists (select 1 from pg_namespace where nspname = 'pgmq') then
-- Revoke privileges on the schema itself
revoke all on schema pgmq from anon, authenticated, service_role;
-- Revoke default privileges for future objects
alter default privileges in schema pgmq revoke all on tables from anon, authenticated, service_role;
alter default privileges in schema pgmq revoke all on sequences from anon, authenticated, service_role;
alter default privileges in schema pgmq revoke all on functions from anon, authenticated, service_role;
end if;
end $$;
drop schema if exists ${QUEUES_SCHEMA};
`)
export async function toggleQueuesExposurePostgrest({
projectRef,
connectionString,
enable,
}: DatabaseQueueExposePostgrestVariables) {
const sql = enable ? EXPOSE_QUEUES_TO_POSTGREST_SQL : HIDE_QUEUES_FROM_POSTGREST_SQL
const { result } = await executeSql({
projectRef,
connectionString,
sql,
queryKey: ['toggle-queues-exposure'],
})
return result
}
type DatabaseQueueExposePostgrestData = Awaited<ReturnType<typeof toggleQueuesExposurePostgrest>>
export const useDatabaseQueueToggleExposeMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<
DatabaseQueueExposePostgrestData,
ResponseError,
DatabaseQueueExposePostgrestVariables
>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<
DatabaseQueueExposePostgrestData,
ResponseError,
DatabaseQueueExposePostgrestVariables
>((vars) => toggleQueuesExposurePostgrest(vars), {
async onSuccess(data, variables, context) {
const { projectRef } = variables
await queryClient.invalidateQueries(databaseQueuesKeys.exposePostgrestStatus(projectRef))
// [Joshen] Schemas can be invalidated without waiting
queryClient.invalidateQueries(databaseKeys.schemas(projectRef))
await onSuccess?.(data, variables, context)
},
async onError(data, variables, context) {
if (onError === undefined) {
toast.error(`Failed to toggle queue exposure via PostgREST: ${data.message}`)
} else {
onError(data, variables, context)
}
},
...options,
})
}
+4 -3
View File
@@ -3,9 +3,10 @@ export const databaseQueuesKeys = {
delete: (name: string) => ['queues', name, 'delete'] as const,
purge: (name: string) => ['queues', name, 'purge'] as const,
getMessagesInfinite: (projectRef: string | undefined, queueName: string, options?: object) =>
['projects', projectRef, 'queues', queueName, options].filter(Boolean),
['projects', projectRef, 'queue-messages', queueName, options].filter(Boolean),
list: (projectRef: string | undefined) => ['projects', projectRef, 'queues'] as const,
// invalidating queues.list will also invalidate queues.metrics
metrics: (projectRef: string | undefined, queueName: string) =>
['projects', projectRef, 'queues', 'metrics', queueName] as const,
['projects', projectRef, 'queue-metrics', queueName] as const,
exposePostgrestStatus: (projectRef: string | undefined) =>
['projects', projectRef, 'queue-expose-status'] as const,
}
+5 -2
View File
@@ -1,7 +1,9 @@
import { QUEUES_SCHEMA } from 'data/database-queues/database-queues-toggle-postgrest-mutation'
/**
* A list of system schemas that users should not interact with
*/
export const EXCLUDED_SCHEMAS = [
export const PROTECTED_SCHEMAS = [
'auth',
'cron',
'extensions',
@@ -18,8 +20,9 @@ export const EXCLUDED_SCHEMAS = [
'vault',
'graphql',
'graphql_public',
QUEUES_SCHEMA,
]
export const EXCLUDED_SCHEMAS_WITHOUT_EXTENSIONS = EXCLUDED_SCHEMAS.filter(
export const PROTECTED_SCHEMAS_WITHOUT_EXTENSIONS = PROTECTED_SCHEMAS.filter(
(x) => x !== 'extensions'
)
@@ -19,7 +19,7 @@ import { useSchemasQuery } from 'data/database/schemas-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions'
import { useUrlState } from 'hooks/ui/useUrlState'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { useAppStateSnapshot } from 'state/app-state'
import type { NextPageWithLayout } from 'types'
import { Input } from 'ui'
@@ -79,7 +79,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
})
const [protectedSchemas] = partition(
schemas,
(schema) => schema?.name !== 'realtime' && EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
(schema) => schema?.name !== 'realtime' && PROTECTED_SCHEMAS.includes(schema?.name ?? '')
)
const selectedSchema = schemas?.find((s) => s.name === schema)
const isLocked = protectedSchemas.some((s) => s.id === selectedSchema?.id)
@@ -27,7 +27,7 @@ import { useTablesQuery } from 'data/tables/tables-query'
import { useLocalStorage } from 'hooks/misc/useLocalStorage'
import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState'
import { LOCAL_STORAGE_KEYS } from 'lib/constants'
import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas'
import { PROTECTED_SCHEMAS } from 'lib/constants/schemas'
import { useAppStateSnapshot } from 'state/app-state'
import type { NextPageWithLayout } from 'types'
import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui'
@@ -133,7 +133,7 @@ const PrivilegesPage: NextPageWithLayout = () => {
const table = tableList?.find(
(table) => table.schema === selectedSchema && table.name === selectedTable
)
const isLocked = EXCLUDED_SCHEMAS.includes(selectedSchema)
const isLocked = PROTECTED_SCHEMAS.includes(selectedSchema)
const {
tableCheckedStates,
@@ -134,7 +134,7 @@ const FormDescription = React.forwardRef<
const { formDescriptionId } = useFormField()
return (
<p
<div
ref={ref}
id={formDescriptionId}
className={cn('text-sm text-foreground-light', className)}