feat: RLS policies for realtime channels (#27490)

* Add queries and mutations for realtime channels.

* Change the layout, add a link for policies and modal for creating a channel.

* Add a page for policies.

* Add templates for realtime tables.

* Fix the spacing in the PolicyDetails component. Add better types.

* Pass templateData to the template generating functions.

* Various CSS fixes.

* Add components for rendering the realtime policies page.

* Fix the editing policy flow.

* Fix refetching when adding or deleting policies.

* Minor various fixes.

* Remove an old console.log.

* More fixes for templates.

* Make the realtime policies open by default when clicking on realtime.

* Update the realtime policy templates.

* Use a dropdown button for the three types of policies.

* Fix a copy-paste mistake.

* Fix icons.

* Fix the toast.

* Clean up the previous components.

* Clean up more unneeded code.

* Remove templateData prop.

* Remove unneeded queries and mutations.

* More cleanup.

* Reuse the components from the Auth Policies.

* Minor fixes for the new components.

* Add a feature flag for AuthZ.

* Revert some changes.

* Fix a build error.

* Fix all definitions.

* Fix an issue with copying large lines which from templates which are word-wrapped.

* Addressed various comments by Joshen.

* Revert the UI changes in the Policy Editor.

* Update the template names.

* Small style fix

---------

Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
Ivan VasilovandJoshen Lim authored and GitHub committed 2024-06-26 13:27:29 +02:00
1 parent 553f2aa7fc
commit ee8f901bfd
14 files changed
+312 -41

No files matched your search

@@ -1,9 +1,10 @@
import { Check, ChevronsUpDown } from 'lucide-react'
import { useEffect, useState } from 'react'
import { UseFormReturn } from 'react-hook-form'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
import { useTablesQuery } from 'data/tables/tables-query'
import { Check, ChevronsUpDown } from 'lucide-react'
import {
Button,
CommandEmpty_Shadcn_,
@@ -37,7 +38,13 @@ interface PolicyDetailsV2Props {
searchString?: string
selectedTable?: string
isEditing: boolean
form: any
form: UseFormReturn<{
name: string
table: string
behavior: string
command: string
roles: string
}>
onUpdateCommand: (command: string) => void
}
@@ -8,7 +8,10 @@ import SimpleCodeBlock from 'components/to-be-cleaned/SimpleCodeBlock'
import CardButton from 'components/ui/CardButton'
import CopyButton from 'components/ui/CopyButton'
import NoSearchResults from 'components/ui/NoSearchResults'
import { getGeneralPolicyTemplates } from '../PolicyEditorModal/PolicyEditorModal.constants'
import {
getGeneralPolicyTemplates,
getRealtimePolicyTemplates,
} from '../PolicyEditorModal/PolicyEditorModal.constants'
interface PolicyTemplatesProps {
schema: string
@@ -26,7 +29,11 @@ export const PolicyTemplates = ({
onSelectTemplate,
}: PolicyTemplatesProps) => {
const [search, setSearch] = useState('')
const templates = getGeneralPolicyTemplates(schema, table.length > 0 ? table : 'table_name')
const templates =
schema === 'realtime'
? getRealtimePolicyTemplates()
: getGeneralPolicyTemplates(schema, table.length > 0 ? table : 'table_name')
const baseTemplates =
selectedPolicy !== undefined
@@ -68,7 +75,7 @@ export const PolicyTemplates = ({
title={template.name}
titleClass="text-sm"
className={cn(
'transition',
'transition w-full',
template.id === selectedTemplate
? '!border-stronger bg-surface-200 hover:!border-stronger'
: ''
@@ -4,9 +4,9 @@ import { MutableRefObject, useEffect, useRef } from 'react'
import { cn } from 'ui'
import { Markdown } from 'components/interfaces/Markdown'
import { noop } from 'lodash'
import { formatQuery } from 'data/sql/format-sql-query'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import { formatQuery } from 'data/sql/format-sql-query'
import { noop } from 'lodash'
// [Joshen] Is there a way we can just have one single MonacoEditor component that's shared across the dashboard?
// Feels like we're creating multiple copies of Editor. I'm keen to make this one the defacto as well so lets make sure
@@ -119,6 +119,12 @@ const RLSCodeEditor = ({
onChange()
}
// when the value has changed, trigger the onChange callback so that the height of the container can be adjusted.
// Happens when the value wordwraps and is updated via a template.
useEffect(() => {
onChange()
}, [value])
async function formatPgsql(value: any) {
try {
const formatted = await formatQuery({
@@ -195,9 +195,9 @@ export const AIPolicyEditorPanel = memo(function ({
}, [chatMessages, debugThread])
const { mutate: executeMutation, isLoading: isExecuting } = useExecuteSqlMutation({
onSuccess: () => {
onSuccess: async () => {
// refresh all policies
queryClient.invalidateQueries(databasePoliciesKeys.list(ref))
await queryClient.invalidateQueries(databasePoliciesKeys.list(ref))
toast.success('Successfully created new policy')
onSelectCancel()
},
@@ -145,7 +145,7 @@ const Policies = ({
<section key={table.id}>
<PolicyTableRow
table={table}
isLocked={isLocked}
isLocked={schema === 'realtime' ? true : isLocked}
onSelectToggleRLS={onSelectToggleRLS}
onSelectCreatePolicy={() => onSelectCreatePolicy(table.name)}
onSelectEditPolicy={onSelectEditPolicy}
@@ -173,3 +173,141 @@ for select using (
roles: [],
},
]
export const getRealtimePolicyTemplates = (): PolicyTemplate[] => {
const results = [
{
id: 'policy-broadcast-1',
preview: false,
templateName: 'Allow listening for broadcasts for authenticated users only',
description: 'This policy allows listening for broadcasts for authenticated users only.',
statement: `
create policy "Allow listening for broadcasts for authenticated users only"
on realtime.messages for select
to authenticated
using ( realtime.messages.extension = 'broadcast' );`.trim(),
name: 'Allow listening for broadcasts for authenticated users only',
definition: "realtime.messages.extension = 'broadcast'",
check: '',
command: 'SELECT',
roles: ['authenticated'],
},
{
id: 'policy-broadcast-2',
preview: false,
templateName: 'Allow pushing broadcasts for authenticated users only',
description: 'This policy allows pushing broadcasts for authenticated users only.',
statement: `
create policy "Allow pushing broadcasts for authenticated users only"
ON realtime.messages for update
TO authenticated
using ( realtime.messages.extension = 'broadcast' )
with check ( realtime.messages.extension = 'broadcast' );`.trim(),
name: 'Allow pushing broadcasts for authenticated users only',
definition: "realtime.messages.extension = 'broadcast'",
check: "realtime.messages.extension = 'broadcast'",
command: 'UPDATE',
roles: ['authenticated'],
},
{
id: 'policy-broadcast-3',
preview: false,
templateName: 'Allow listening for broadcasts from a specific channel',
description: 'This policy allows listening for broadcasts from a specific channel.',
statement: `
create policy "Allow listening for broadcasts from a specific channel"
on realtime.messages for select
using ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(),
name: 'Allow listening for broadcasts from a specific channel',
definition: `realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
check: '',
command: 'SELECT',
roles: [],
},
{
id: 'policy-broadcast-4',
preview: false,
templateName: 'Allow pushing broadcasts to specific channel',
description: 'This policy allow pushing broadcasts to specific channel.',
statement: `
create policy "Allow pushing broadcasts to specific channel"
ON realtime.messages for update
using ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' )
with check ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(),
name: 'Allow pushing broadcasts to specific channel',
definition: `realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
check: `realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`,
command: 'UPDATE',
roles: [],
},
{
id: 'policy-presences-1',
preview: false,
templateName: 'Allow listening for presences on all channels for authenticated users only',
description:
'This policy enables listening for presences on all channels for all authenticated users only.',
statement: `
create policy "Allow listening for presences on all channels for authenticated users only"
on realtime.messages for select
to authenticated
using ( realtime.messages.extension = 'presence' );`.trim(),
name: 'Allow listening for presences on all channels for authenticated users only',
definition: "realtime.messages.extension = 'presence'",
check: '',
command: 'SELECT',
roles: ['authenticated'],
},
{
id: 'policy-presences-2',
preview: false,
templateName: 'Allow broadcasting presences on all channels for authenticated users only',
description:
'This policy enables broadcasting presences on all channels for all authenticated users only.',
statement: `
create policy "Allow broadcasting presences on all channels for authenticated users only"
ON realtime.messages for update
TO authenticated
using ( realtime.messages.extension = 'presence' )
with check ( realtime.messages.extension = 'presence' );
;`.trim(),
name: 'Allow broadcasting presences on all channels for authenticated users only',
definition: "realtime.messages.extension = 'presence'",
check: "realtime.messages.extension = 'presence'",
command: 'UPDATE',
roles: ['authenticated'],
},
{
id: 'policy-presences-3',
preview: false,
templateName: 'Allow listening for presences from a specific channel',
description: 'This policy enables listening for presences from a specific channel.',
statement: `
create policy "Allow listening for presences from a specific channel"
on realtime.messages for select
using ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );`.trim(),
name: 'Allow listening for presences from a specific channel',
definition: `realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
check: '',
command: 'SELECT',
roles: [],
},
{
id: 'policy-presences-4',
preview: false,
templateName: 'Publish presence to a specific channel',
description: 'This policy allows publishing presence to a specific channel.',
statement: `
create policy "Publish presence to a specific channel"
ON realtime.messages for update
using ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' )
with check ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );
;`.trim(),
name: 'Publish presence to a specific channel',
definition: `realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
check: `realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`,
command: 'UPDATE',
roles: [],
},
] as PolicyTemplate[]
return results
}
@@ -8,7 +8,7 @@ import { useRouter } from 'next/router'
import { useIsRLSAIAssistantEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext'
import { useCheckPermissions } from 'hooks'
import { Badge, Button } from 'ui'
import { Badge, Button, TooltipContent_Shadcn_, TooltipTrigger_Shadcn_, Tooltip_Shadcn_ } from 'ui'
interface PolicyTableRowHeaderProps {
table: PostgresTable
@@ -29,6 +29,10 @@ const PolicyTableRowHeader = ({
const isAiAssistantEnabled = useIsRLSAIAssistantEnabled()
const canToggleRLS = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables')
const isRealtimeSchema = table.schema === 'realtime'
const isRealtimeMessagesTable = isRealtimeSchema && table.name === 'messages'
const isTableLocked = isRealtimeSchema ? !isRealtimeMessagesTable : isLocked
return (
<div id={table.id.toString()} className="flex w-full items-center justify-between">
<div className="flex gap-x-4 text-left">
@@ -36,7 +40,7 @@ const PolicyTableRowHeader = ({
<h4 className="m-0">{table.name}</h4>
</Link>
<div className="flex items-center gap-x-2">
{isLocked && (
{isTableLocked && (
<Badge>
<span className="flex gap-2 items-center text-xs uppercase text-foreground-lighter">
<Lock size={12} /> Locked
@@ -48,22 +52,22 @@ const PolicyTableRowHeader = ({
</Badge>
</div>
</div>
{!isLocked && (
{!isTableLocked && (
<div className="flex-1">
<div className="flex flex-row justify-end gap-x-2">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
type="default"
disabled={!canToggleRLS}
onClick={() => onSelectToggleRLS(table)}
>
{table.rls_enabled ? 'Disable RLS' : 'Enable RLS'}
</Button>
</Tooltip.Trigger>
{!canToggleRLS && (
<Tooltip.Portal>
<Tooltip.Content side="bottom">
{!isRealtimeMessagesTable ? (
<Tooltip_Shadcn_ delayDuration={0}>
<TooltipTrigger_Shadcn_ asChild>
<Button
type="default"
disabled={!canToggleRLS}
onClick={() => onSelectToggleRLS(table)}
>
{table.rls_enabled ? 'Disable RLS' : 'Enable RLS'}
</Button>
</TooltipTrigger_Shadcn_>
{!canToggleRLS && (
<TooltipContent_Shadcn_ side="bottom">
<Tooltip.Arrow className="radix-tooltip-arrow" />
<div
className={[
@@ -75,10 +79,10 @@ const PolicyTableRowHeader = ({
You need additional permissions to toggle RLS
</span>
</div>
</Tooltip.Content>
</Tooltip.Portal>
)}
</Tooltip.Root>
</TooltipContent_Shadcn_>
)}
</Tooltip_Shadcn_>
) : null}
{!isAiAssistantEnabled && (
<Button
type="default"
@@ -0,0 +1,72 @@
import { PostgresPolicy } from '@supabase/postgres-meta'
import { useState } from 'react'
import { Policies } from 'components/interfaces/Auth/Policies'
import { AIPolicyEditorPanel } from 'components/interfaces/Auth/Policies/AIPolicyEditorPanel'
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
import AlertError from 'components/ui/AlertError'
import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader'
import { useTablesQuery } from 'data/tables/tables-query'
import { FormHeader } from 'components/ui/Forms'
export const RealtimePolicies = () => {
const { project } = useProjectContext()
const [showPolicyEditor, setShowPolicyEditor] = useState(false)
const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState<PostgresPolicy>()
const {
data: tables,
isLoading,
isSuccess,
isError,
error,
} = useTablesQuery({
projectRef: project?.ref,
connectionString: project?.connectionString,
schema: 'realtime',
})
const filteredTables = (tables ?? []).filter((table) => table.name === 'messages')
return (
<div className="flex min-h-full w-full flex-col p-4 gap-y-4">
<FormHeader
className="!mb-0"
title="Realtime policies"
description="You can use RLS policies to control access to Realtime Channels"
/>
{isLoading && <GenericSkeletonLoader />}
{isError && <AlertError error={error} subject="Failed to retrieve tables" />}
{isSuccess && (
<div className="space-y-4">
<Policies
schema="realtime"
tables={filteredTables}
hasTables
isLocked={false}
onSelectCreatePolicy={() => {
setSelectedPolicyToEdit(undefined)
setShowPolicyEditor(true)
}}
onSelectEditPolicy={(policy) => {
setSelectedPolicyToEdit(policy)
setShowPolicyEditor(true)
}}
/>
</div>
)}
<AIPolicyEditorPanel
visible={showPolicyEditor}
searchString="messages"
schema="realtime"
selectedPolicy={selectedPolicyToEdit}
onSelectCancel={() => setShowPolicyEditor(false)}
/>
</div>
)
}
@@ -2,25 +2,29 @@ import { useRouter } from 'next/router'
import { PropsWithChildren } from 'react'
import { ProductMenu } from 'components/ui/ProductMenu'
import { useSelectedProject, withAuth } from 'hooks'
import { useFlag, useSelectedProject, withAuth } from 'hooks'
import { ProjectLayout } from '../'
import { generateRealtimeMenu } from './RealtimeMenu.utils'
export interface RealtimeLayoutProps {
title?: string
title: string
}
const RealtimeLayout = ({ children }: PropsWithChildren<RealtimeLayoutProps>) => {
const RealtimeLayout = ({ title, children }: PropsWithChildren<RealtimeLayoutProps>) => {
const project = useSelectedProject()
const router = useRouter()
const page = router.pathname.split('/')[4]
const authzEnabled = useFlag('authzRealtime')
return (
<ProjectLayout
isLoading={false}
title={title}
product="Realtime"
productMenu={<ProductMenu page={page} menu={generateRealtimeMenu(project)} />}
productMenu={
<ProductMenu page={page} menu={generateRealtimeMenu(project!, { authzEnabled })} />
}
>
{children}
</ProjectLayout>
@@ -1,12 +1,15 @@
import type { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types'
import type { Project } from 'data/projects/project-detail-query'
export const generateRealtimeMenu = (project?: Project): ProductMenuGroup[] => {
export const generateRealtimeMenu = (
project: Project,
{ authzEnabled }: { authzEnabled: boolean }
): ProductMenuGroup[] => {
const ref = project?.ref ?? 'default'
return [
{
title: 'Realtime',
title: 'Tools',
items: [
{
name: 'Inspector',
@@ -16,5 +19,20 @@ export const generateRealtimeMenu = (project?: Project): ProductMenuGroup[] => {
},
],
},
...(authzEnabled
? [
{
title: 'Configuration',
items: [
{
name: 'Policies',
key: 'policies',
url: `/project/${ref}/realtime/policies`,
items: [],
},
],
},
]
: []),
]
}
+1 -1
View File
@@ -1,4 +1,4 @@
export const databasePoliciesKeys = {
list: (projectRef: string | undefined, schema?: string | undefined) =>
['projects', projectRef, 'database-policies', schema] as const,
['projects', projectRef, 'database-policies', schema].filter(Boolean),
}
@@ -73,8 +73,9 @@ const AuthPoliciesPage: NextPageWithLayout = () => {
projectRef: project?.ref,
connectionString: project?.connectionString,
})
const [protectedSchemas] = partition(schemas, (schema) =>
EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
const [protectedSchemas] = partition(
schemas,
(schema) => schema?.name !== 'realtime' && EXCLUDED_SCHEMAS.includes(schema?.name ?? '')
)
const selectedSchema = schemas?.find((s) => s.name === schema)
const isLocked = protectedSchemas.some((s) => s.id === selectedSchema?.id)
@@ -16,6 +16,8 @@ export const InspectorPage: NextPageWithLayout = () => {
return <RealtimeInspector />
}
InspectorPage.getLayout = (page) => <RealtimeLayout title="Realtime">{page}</RealtimeLayout>
InspectorPage.getLayout = (page) => (
<RealtimeLayout title="Realtime Inspector">{page}</RealtimeLayout>
)
export default InspectorPage
@@ -0,0 +1,12 @@
import { RealtimePolicies } from 'components/interfaces/Realtime/Policies'
import type { NextPageWithLayout } from 'types'
import RealtimeLayout from 'components/layouts/RealtimeLayout/RealtimeLayout'
const RealtimePoliciesPage: NextPageWithLayout = () => {
return <RealtimePolicies />
}
RealtimePoliciesPage.getLayout = (page) => <RealtimeLayout title="Policies">{page}</RealtimeLayout>
export default RealtimePoliciesPage