mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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:
1 parent
48c0578ff7
commit
d8a57c1c7e
44 files changed
+1471
-137
No files matched your search
+4
-1
@@ -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
|
||||
|
||||
+18
@@ -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)}
|
||||
/>
|
||||
|
||||
+2
-2
@@ -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) => {
|
||||
|
||||
+2
-2
@@ -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({
|
||||
|
||||
+2
-2
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user