From d8a57c1c7e5a19bbbd6df91dd187d74e18a69bf8 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 27 Nov 2024 12:10:33 +0800 Subject: [PATCH] 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 --- .../AIPolicyEditorPanel/PolicyTemplates.tsx | 5 +- .../PolicyEditorModal.constants.ts | 18 + .../Policies/PolicyTableRow/PolicyRow.tsx | 60 +-- .../EnumeratedTypes/EnumeratedTypes.tsx | 4 +- .../Functions/CreateFunction/index.tsx | 4 +- .../Functions/FunctionsList/FunctionsList.tsx | 4 +- .../interfaces/Database/Indexes/Indexes.tsx | 4 +- .../Database/ProtectedSchemaWarning.tsx | 4 +- .../Publications/PublicationsTables.tsx | 6 +- .../interfaces/Database/Tables/ColumnList.tsx | 4 +- .../interfaces/Database/Tables/TableList.tsx | 4 +- .../Database/Triggers/CreateTrigger.tsx | 4 +- .../Triggers/TriggersList/TriggersList.tsx | 15 +- .../DiskManagement/DiskManagementForm.tsx | 2 +- .../DiskManagementPanelForm.tsx | 2 +- .../Landing/Integrations.constants.tsx | 17 +- .../Integrations/Queues/CreateQueueSheet.tsx | 112 ++++-- .../Integrations/Queues/OverviewTab.tsx | 40 ++ .../Integrations/Queues/QueueTab.tsx | 207 ++++++++++- .../Integrations/Queues/QueuesRows.tsx | 16 +- .../Integrations/Queues/QueuesSettings.tsx | 342 ++++++++++++++++++ .../Integrations/Queues/QueuesTab.tsx | 5 +- .../Queues/SingleQueue/DeleteQueue.tsx | 2 +- .../Queues/SingleQueue/QueueDataGrid.tsx | 8 + .../Queues/SingleQueue/QueueSettings.tsx | 302 ++++++++++++++++ .../interfaces/TableGridEditor/EmptyState.tsx | 4 +- .../TableGridEditor/GridHeaderActions.tsx | 7 +- .../ColumnEditor/ColumnEditor.tsx | 4 +- .../TableEditor/TableEditor.tsx | 4 +- .../TableGridEditor/TableGridEditor.tsx | 4 +- .../components/layouts/Integrations/tabs.tsx | 2 +- .../ProjectIntegrationsLayout.tsx | 1 - .../SQLEditorLayout/SqlEditor.Commands.tsx | 4 +- .../TableEditorLayout/TableEditorMenu.tsx | 4 +- .../config/project-postgrest-config-query.ts | 3 +- .../database-queues-create-mutation.ts | 26 +- .../database-queues-delete-mutation.ts | 5 +- ...se-queues-expose-postgrest-status-query.ts | 47 +++ ...tabase-queues-toggle-postgrest-mutation.ts | 278 ++++++++++++++ apps/studio/data/database-queues/keys.ts | 7 +- apps/studio/lib/constants/schemas.ts | 7 +- .../pages/project/[ref]/auth/policies.tsx | 4 +- .../[ref]/database/column-privileges.tsx | 4 +- packages/ui/src/components/shadcn/ui/form.tsx | 2 +- 44 files changed, 1471 insertions(+), 137 deletions(-) create mode 100644 apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx create mode 100644 apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx create mode 100644 apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx create mode 100644 apps/studio/data/database-queues/database-queues-expose-postgrest-status-query.ts create mode 100644 apps/studio/data/database-queues/database-queues-toggle-postgrest-mutation.ts diff --git a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx index 0edb730208b..2e12c835206 100644 --- a/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/AIPolicyEditorPanel/PolicyTemplates.tsx @@ -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 diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts index f941cf82d19..2c0c280534c 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts @@ -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'], + }, + ] +} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx index c8d7abb4295..f0c174e11e7 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyRow.tsx @@ -62,37 +62,43 @@ const PolicyRow = ({ 'w-full last:border-0 space-x-4 border-b py-4 lg:items-center' )} > -
-
-

{policy.command}

-

{policy.name}

+
+
+

+ {policy.command} +

+ +
+

{policy.name}

+
+
+ Applied to: + {policy.roles.slice(0, 3).map((role, i) => ( + + {role} + + ))}{' '} + role +
+ {policy.roles.length > 3 && ( + + + + + {policy.roles.length - 3} more roles + + + + {policy.roles.slice(3).join(', ')} + + + )} +
+
+ {appliesToAnonymousUsers ? ( Applies to anonymous users ) : null}
-
-
- Applied to: - {policy.roles.slice(0, 3).map((role, i) => ( - - {role} - - ))}{' '} - role -
- {policy.roles.length > 3 && ( - - - - + {policy.roles.length - 3} more roles - - - - {policy.roles.slice(3).join(', ')} - - - )} -
{!isLocked && ( diff --git a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx index 752b9a9ae8a..b6b7481c3eb 100644 --- a/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx +++ b/apps/studio/components/interfaces/Database/EnumeratedTypes/EnumeratedTypes.tsx @@ -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) diff --git a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx index 8d440869f36..54f5317c97c 100644 --- a/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx +++ b/apps/studio/components/interfaces/Database/Functions/CreateFunction/index.tsx @@ -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) => { field.onChange(name)} /> diff --git a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx index 5b0e7b1de75..609998fd789 100644 --- a/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx +++ b/apps/studio/components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx @@ -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) diff --git a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx index 2a82a604860..5d4b4b03336 100644 --- a/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx +++ b/apps/studio/components/interfaces/Database/Indexes/Indexes.tsx @@ -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) diff --git a/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx b/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx index fae537f223e..c933295ddbb 100644 --- a/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx +++ b/apps/studio/components/interfaces/Database/ProtectedSchemaWarning.tsx @@ -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.

- {EXCLUDED_SCHEMAS.map((schema) => ( + {PROTECTED_SCHEMAS.map((schema) => ( {schema} diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx index dff11cc7ba0..fe0f306660f 100644 --- a/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx +++ b/apps/studio/components/interfaces/Database/Publications/PublicationsTables.tsx @@ -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) ) }, } diff --git a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx index e23b406b17c..c9589f0e2fd 100644 --- a/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/ColumnList.tsx @@ -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 ( diff --git a/apps/studio/components/interfaces/Database/Tables/TableList.tsx b/apps/studio/components/interfaces/Database/Tables/TableList.tsx index 60da3feaed6..53e4fea6048 100644 --- a/apps/studio/components/interfaces/Database/Tables/TableList.tsx +++ b/apps/studio/components/interfaces/Database/Tables/TableList.tsx @@ -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 diff --git a/apps/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx b/apps/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx index eb96c6c1877..8cf605d89f1 100644 --- a/apps/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/CreateTrigger.tsx @@ -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() } diff --git a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx index 269489d8840..fd2af38317e 100644 --- a/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx +++ b/apps/studio/components/interfaces/Database/Triggers/TriggersList/TriggersList.tsx @@ -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) diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx index ab1c663830f..2080541b90b 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementForm.tsx @@ -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, diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx index d4995590490..b898d8e009c 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementPanelForm.tsx @@ -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 diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx index ff58555f2c3..1bcaf7b4331 100644 --- a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -76,6 +76,10 @@ const supabaseIntegrations: IntegrationDefinition[] = [ ), }, + { + 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 }, diff --git a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx index 9cefe7df052..69c6e132b2c 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/CreateQueueSheet.tsx @@ -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({ 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 = 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 = 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} > -
+
{definition.icon}
-
-
-

{definition.label}

+
+
+

{definition.label}

+ {definition.value === 'partitioned' && ( + Coming soon + )}
-

+

{definition.description}

- {definition.value === 'partitioned' ? ( -
- COMING SOON -
- ) : null} {/* {!pgPartmanExtensionInstalled && definition.value === 'partitioned' ? (
@@ -287,8 +296,53 @@ export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQue )} /> + )} + + ( + +

Enable Row Level Security (RLS)

+ Recommended +
+ } + description="Restrict access to your queue by enabling RLS and writing Postgres policies to control access for each role." + > + + + + + )} + /> + {!isExposed ? ( + + + + ) : ( + + )} +
diff --git a/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx new file mode 100644 index 00000000000..bd7598b1a9e --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/OverviewTab.tsx @@ -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 ( + +

+ You may choose to toggle the exposure of Queues through PostgREST via the queues + settings +

+ + + ) : null + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx index 0fa0c08fd91..56c19e5344e 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueueTab.tsx @@ -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([]) - 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 (
-
+ } + > + + Auth {queuePolicies.length > 1 ? 'policies' : 'policy'} + + + )} + + ) : ( + setOpenRlsPopover(!openRlsPopover)} + > + + + + +

+ Row Level Security (RLS) +

+
+ {isExposed ? ( + <> +

+ You can restrict and control who can manage this queue using Row Level + Security. +

+

With RLS enabled, anonymous users will not have access to this queue.

+ + + ) : ( + <> + + + + )} +
+
+
+ )} + @@ -64,6 +240,7 @@ export const QueueTab = () => { setSendMessageModalShown(true)} @@ -83,6 +260,20 @@ export const QueueTab = () => { visible={purgeQueueModalShown} onClose={() => setPurgeQueueModalShown(false)} /> + + setRlsConfirmModalOpen(false)} + onConfirm={() => onToggleRLS()} + > +

+ Are you sure you want to enable Row Level Security for the queue "{queueName}"? +

+
) } diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx index ca79a1a42d5..852512632b5 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx @@ -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}

+ +
+ {isRlsEnabled ? : } +
+

{dayjs(queue.created_at).format(DATETIME_FORMAT)}

diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx new file mode 100644 index 00000000000..5181053db59 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesSettings.tsx @@ -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>({ + 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) => { + 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 ( + <> +
+ + +
+ + + ( + + +

+ When enabled, you will be able to use the following functions from the{' '} + {QUEUES_SCHEMA} schema to manage your + queues via any Supabase client library or PostgREST endpoints: +

+

+ queue_send,{' '} + queue_send_batch,{' '} + queue_read,{' '} + queue_pop, + queue_archive, and + queue_delete +

+ + } + > + + 0 || !canUpdatePostgrestConfig + } + checked={field.value} + onCheckedChange={(value) => field.onChange(value)} + /> + +
+ {tablesWithoutRLS.length > 0 && ( + +

+ Please ensure that the following {tablesWithoutRLS.length} queue + {tablesWithoutRLS.length > 1 ? 's' : ''} have RLS enabled in order to + prevent anonymous access. +

+
    + {tablesWithoutRLS.map((x) => { + return ( +
  • + {x.name.slice(2)} +
  • + ) + })} +
+ + +
+ )} + {formState.dirtyFields.enable && field.value === true && ( + +

+ Queues will be exposed and managed through the{' '} + {QUEUES_SCHEMA} schema +

+

+ Database functions will be created in the{' '} + {QUEUES_SCHEMA} 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). +

+
+ )} + {formState.dirtyFields.enable && field.value === false && ( + +

+ The {QUEUES_SCHEMA} schema will be + removed once disabled +

+

+ Ensure that the database functions from the{' '} + {QUEUES_SCHEMA} schema are not in use + within your client applications before disabling. +

+
+ )} +
+ )} + /> +
+ + + +
+ + +
+
+
+
+
+
+ + setRlsConfirmModalOpen(false)} + onConfirm={() => onToggleRLS()} + > +

+ Are you sure you want to enable Row Level Security for the following queues: +

+
    + {tablesWithoutRLS.map((x) => { + return ( +
  • + {x.name.slice(2)} +
  • + ) + })} +
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx index fa1db6f9bb8..5cb68eff63c 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesTab.tsx @@ -78,6 +78,9 @@ export const QueuesTab = () => { Type + +
RLS enabled
+
Created at @@ -94,7 +97,7 @@ export const QueuesTab = () => {
setIsClosingCreateQueueSheet(true)}> - + { setIsClosingCreateQueueSheet(false) diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx index 20c9e6812f8..109dd4835b0 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/DeleteQueue.tsx @@ -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() }, }) diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx index f9012f5c60c..cb57532557c 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx @@ -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 = ({
+ ) : !!error ? ( +
+ +
) : (
diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx new file mode 100644 index 00000000000..fd475eb5acb --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueSettings.tsx @@ -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 ( + + + } + title="Settings" + tooltip={{ content: { side: 'bottom', text: 'Queue settings' } }} + /> + + + + Manage queue permissions on {name} + + Configure permissions for each role to grant access to the relevant actions on the queue + + + + + + + + Role + {ACTIONS.map((x) => ( + + {x} + + ))} + + + + {isLoading && ( + <> + + + + + + + + + + + + + + + + + )} + {isError && ( + + + + + + )} + {isSuccess && + (roles ?? []).map((role) => { + return ( + + {role.name} + {ACTIONS.map((x) => ( + + onTogglePrivilege(role.name, x, value)} + /> + + ))} + + ) + })} + +
+
+ + + + +
+
+ ) +} diff --git a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx index 07a77e633d8..0fbf052477c 100644 --- a/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/EmptyState.tsx @@ -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 diff --git a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx index 3562f5a8f2e..eaad8f754cf 100644 --- a/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/GridHeaderActions.tsx @@ -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) => { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index d4a9cab8f33..408f3ecce1f 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -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({ diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index cc792afd031..d4058972d45 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -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({ diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index ff4ef5d7127..288e6025635 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -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) diff --git a/apps/studio/components/layouts/Integrations/tabs.tsx b/apps/studio/components/layouts/Integrations/tabs.tsx index 1d53f7c8567..bc9c475d4a9 100644 --- a/apps/studio/components/layouts/Integrations/tabs.tsx +++ b/apps/studio/components/layouts/Integrations/tabs.tsx @@ -71,7 +71,7 @@ export const IntegrationTabs = ({ scroll, isSticky }: IntegrationTabsProps) => { const tabUrl = `/project/${project?.ref}/integrations/${integration?.id}/${tab.route}` return (
- + {tab.label} diff --git a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx index 1366ec37662..b9496ba2ba6 100644 --- a/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx +++ b/apps/studio/components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout.tsx @@ -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 ( diff --git a/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx b/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx index 56761c03108..7e2a8253966 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx @@ -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 diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index eef43fb93f9..cbbf0cdad3e 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -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) diff --git a/apps/studio/data/config/project-postgrest-config-query.ts b/apps/studio/data/config/project-postgrest-config-query.ts index fcf36806e33..58b89563145 100644 --- a/apps/studio/data/config/project-postgrest-config-query.ts +++ b/apps/studio/data/config/project-postgrest-config-query.ts @@ -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> -export type ProjectPostgrestConfigError = unknown +export type ProjectPostgrestConfigError = ResponseError export const useProjectPostgrestConfigQuery = ( { projectRef }: ProjectPostgrestConfigVariables, diff --git a/apps/studio/data/database-queues/database-queues-create-mutation.ts b/apps/studio/data/database-queues/database-queues-create-mutation.ts index 7f08d7a9840..92c9e43d3b0 100644 --- a/apps/studio/data/database-queues/database-queues-create-mutation.ts +++ b/apps/studio/data/database-queues/database-queues-create-mutation.ts @@ -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) { diff --git a/apps/studio/data/database-queues/database-queues-delete-mutation.ts b/apps/studio/data/database-queues/database-queues-delete-mutation.ts index 83c2c828359..79a043428bc 100644 --- a/apps/studio/data/database-queues/database-queues-delete-mutation.ts +++ b/apps/studio/data/database-queues/database-queues-delete-mutation.ts @@ -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) { diff --git a/apps/studio/data/database-queues/database-queues-expose-postgrest-status-query.ts b/apps/studio/data/database-queues/database-queues-expose-postgrest-status-query.ts new file mode 100644 index 00000000000..353c162aa62 --- /dev/null +++ b/apps/studio/data/database-queues/database-queues-expose-postgrest-status-query.ts @@ -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 = ( + { projectRef, connectionString }: DatabaseQueuesVariables, + { enabled = true, ...options }: UseQueryOptions = {} +) => + useQuery( + databaseQueuesKeys.exposePostgrestStatus(projectRef), + () => getDatabaseQueuesExposePostgrestStatus({ projectRef, connectionString }), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/database-queues/database-queues-toggle-postgrest-mutation.ts b/apps/studio/data/database-queues/database-queues-toggle-postgrest-mutation.ts new file mode 100644 index 00000000000..5265c2527ab --- /dev/null +++ b/apps/studio/data/database-queues/database-queues-toggle-postgrest-mutation.ts @@ -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> + +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, + }) +} diff --git a/apps/studio/data/database-queues/keys.ts b/apps/studio/data/database-queues/keys.ts index 228c5caa694..c902274df00 100644 --- a/apps/studio/data/database-queues/keys.ts +++ b/apps/studio/data/database-queues/keys.ts @@ -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, } diff --git a/apps/studio/lib/constants/schemas.ts b/apps/studio/lib/constants/schemas.ts index c59e5025b4b..0d15f8ff19a 100644 --- a/apps/studio/lib/constants/schemas.ts +++ b/apps/studio/lib/constants/schemas.ts @@ -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' ) diff --git a/apps/studio/pages/project/[ref]/auth/policies.tsx b/apps/studio/pages/project/[ref]/auth/policies.tsx index 297fd46921e..b6e38e3e40d 100644 --- a/apps/studio/pages/project/[ref]/auth/policies.tsx +++ b/apps/studio/pages/project/[ref]/auth/policies.tsx @@ -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) diff --git a/apps/studio/pages/project/[ref]/database/column-privileges.tsx b/apps/studio/pages/project/[ref]/database/column-privileges.tsx index a8a881a5c96..666008e679f 100644 --- a/apps/studio/pages/project/[ref]/database/column-privileges.tsx +++ b/apps/studio/pages/project/[ref]/database/column-privileges.tsx @@ -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, diff --git a/packages/ui/src/components/shadcn/ui/form.tsx b/packages/ui/src/components/shadcn/ui/form.tsx index e480d454583..37271f4d3b2 100644 --- a/packages/ui/src/components/shadcn/ui/form.tsx +++ b/packages/ui/src/components/shadcn/ui/form.tsx @@ -134,7 +134,7 @@ const FormDescription = React.forwardRef< const { formDescriptionId } = useFormField() return ( -