diff --git a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx index 59382c015e36c..d5465b2b6c35a 100644 --- a/apps/studio/components/interfaces/Auth/Policies/Policies.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/Policies.tsx @@ -1,7 +1,7 @@ import type { PostgresPolicy } from '@supabase/postgres-meta' import { isEmpty } from 'lodash' import Link from 'next/link' -import { useState } from 'react' +import { useCallback, useState } from 'react' import { toast } from 'sonner' import { useParams } from 'common' @@ -23,6 +23,7 @@ interface PoliciesProps { tables: PolicyTableRowProps['table'][] hasTables: boolean isLocked: boolean + visibleTableIds: Set onSelectCreatePolicy: (table: string) => void onSelectEditPolicy: (policy: PostgresPolicy) => void onResetSearch?: () => void @@ -34,6 +35,7 @@ export const Policies = ({ tables, hasTables, isLocked, + visibleTableIds, onSelectCreatePolicy, onSelectEditPolicy: onSelectEditPolicyAI, onResetSearch, @@ -66,27 +68,28 @@ export const Policies = ({ }, }) - const closeConfirmModal = () => { + const closeConfirmModal = useCallback(() => { setSelectedPolicyToDelete({}) setSelectedTableToToggleRLS(undefined) - } + }, []) - const onSelectToggleRLS = (table: { - id: number - schema: string - name: string - rls_enabled: boolean - }) => { - setSelectedTableToToggleRLS(table) - } + const onSelectToggleRLS = useCallback( + (table: { id: number; schema: string; name: string; rls_enabled: boolean }) => { + setSelectedTableToToggleRLS(table) + }, + [] + ) - const onSelectEditPolicy = (policy: any) => { - onSelectEditPolicyAI(policy) - } + const onSelectEditPolicy = useCallback( + (policy: PostgresPolicy) => { + onSelectEditPolicyAI(policy) + }, + [onSelectEditPolicyAI] + ) - const onSelectDeletePolicy = (policy: any) => { + const onSelectDeletePolicy = useCallback((policy: PostgresPolicy) => { setSelectedPolicyToDelete(policy) - } + }, []) // Methods that involve some API const onToggleRLS = async () => { @@ -116,6 +119,13 @@ export const Policies = ({ }) } + const handleCreatePolicy = useCallback( + (tableData: PolicyTableRowProps['table']) => { + onSelectCreatePolicy(tableData.name) + }, + [onSelectCreatePolicy] + ) + if (!hasTables) { return ( @@ -139,18 +149,26 @@ export const Policies = ({
{isLocked && } {tables.length > 0 ? ( - tables.map((table) => ( -
- onSelectCreatePolicy(table.name)} - onSelectEditPolicy={onSelectEditPolicy} - onSelectDeletePolicy={onSelectDeletePolicy} - /> -
- )) + <> + {tables.map((table) => { + const isVisible = visibleTableIds.has(table.id) + return ( + + ) + })} + {!!search && visibleTableIds.size === 0 && ( + + )} + ) : hasTables ? ( ) : null} diff --git a/apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx b/apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx new file mode 100644 index 0000000000000..cd2347c94e741 --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Policies/PoliciesDataContext.tsx @@ -0,0 +1,82 @@ +import type { PostgresPolicy } from '@supabase/postgres-meta' +import type { PropsWithChildren } from 'react' +import { createContext, useCallback, useContext, useMemo } from 'react' + +import type { ResponseError } from 'types' + +type TableKey = `${string}.${string}` + +type PoliciesDataContextValue = { + getPoliciesForTable: (schema: string, table: string) => PostgresPolicy[] + isPoliciesLoading: boolean + isPoliciesError: boolean + policiesError?: ResponseError | Error + exposedSchemas: Set +} + +const PoliciesDataContext = createContext(null) + +export const usePoliciesData = () => { + const context = useContext(PoliciesDataContext) + if (!context) throw new Error('usePoliciesData must be used within PoliciesDataProvider') + return context +} + +type PoliciesDataProviderProps = { + policies: PostgresPolicy[] + isPoliciesLoading: boolean + isPoliciesError: boolean + policiesError?: ResponseError | Error + exposedSchemas: string[] +} + +export const PoliciesDataProvider = ({ + children, + policies, + isPoliciesLoading, + isPoliciesError, + policiesError, + exposedSchemas, +}: PropsWithChildren) => { + const policiesByTable = useMemo(() => { + const map = new Map() + + for (const policy of policies) { + const key = `${policy.schema}.${policy.table}` satisfies TableKey + const existing = map.get(key) + if (existing) { + existing.push(policy) + } else { + map.set(key, [policy]) + } + } + + for (const list of map.values()) { + list.sort((a, b) => a.name.localeCompare(b.name)) + } + + return map + }, [policies]) + + const getPoliciesForTable = useCallback( + (schema: string, table: string) => policiesByTable.get(`${schema}.${table}`) ?? [], + [policiesByTable] + ) + + const exposedSchemasSet = useMemo(() => new Set(exposedSchemas), [exposedSchemas]) + + const contextValue = useMemo( + () => ({ + getPoliciesForTable, + isPoliciesLoading, + isPoliciesError, + policiesError, + exposedSchemas: exposedSchemasSet, + }), + [getPoliciesForTable, isPoliciesLoading, isPoliciesError, policiesError, exposedSchemasSet] + ) + + return ( + {children} + ) +} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.types.ts b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.types.ts new file mode 100644 index 0000000000000..74665744f231a --- /dev/null +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.types.ts @@ -0,0 +1,6 @@ +export type PolicyTable = { + id: number + schema: string + name: string + rls_enabled: boolean +} diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index 27fd57ac00a90..40953561bd3a6 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -8,22 +8,13 @@ import { EditorTablePageLink } from 'data/prefetchers/project.$ref.editor.$id' import { useAsyncCheckPermissions } from 'hooks/misc/useCheckPermissions' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' import { AiIconAnimation, Badge, CardTitle } from 'ui' +import type { PolicyTable } from './PolicyTableRow.types' interface PolicyTableRowHeaderProps { - table: { - id: number - schema: string - name: string - rls_enabled: boolean - } + table: PolicyTable isLocked: boolean - onSelectToggleRLS: (table: { - id: number - schema: string - name: string - rls_enabled: boolean - }) => void - onSelectCreatePolicy: () => void + onSelectToggleRLS: (table: PolicyTable) => void + onSelectCreatePolicy: (table: PolicyTable) => void } export const PolicyTableRowHeader = ({ @@ -91,7 +82,7 @@ export const PolicyTableRowHeader = ({ onSelectCreatePolicy()} + onClick={() => onSelectCreatePolicy(table)} tooltip={{ content: { side: 'bottom', diff --git a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx index b13784f317be1..f7588b524d229 100644 --- a/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx +++ b/apps/studio/components/interfaces/Auth/Policies/PolicyTableRow/index.tsx @@ -1,11 +1,10 @@ import type { PostgresPolicy } from '@supabase/postgres-meta' import { noop } from 'lodash' +import { memo, useMemo } from 'react' import { useParams } from 'common' import AlertError from 'components/ui/AlertError' import { InlineLink } from 'components/ui/InlineLink' -import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' -import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import { useTablesRolesAccessQuery } from 'data/tables/tables-roles-access-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { @@ -23,38 +22,37 @@ import { } from 'ui' import { Admonition } from 'ui-patterns' import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { usePoliciesData } from '../PoliciesDataContext' import { PolicyRow } from './PolicyRow' +import type { PolicyTable } from './PolicyTableRow.types' import { PolicyTableRowHeader } from './PolicyTableRowHeader' export interface PolicyTableRowProps { - table: { - id: number - schema: string - name: string - rls_enabled: boolean - } + table: PolicyTable isLocked: boolean - onSelectToggleRLS: (table: { - id: number - schema: string - name: string - rls_enabled: boolean - }) => void - onSelectCreatePolicy: () => void + onSelectToggleRLS: (table: PolicyTable) => void + onSelectCreatePolicy: (table: PolicyTable) => void onSelectEditPolicy: (policy: PostgresPolicy) => void onSelectDeletePolicy: (policy: PostgresPolicy) => void } -export const PolicyTableRow = ({ +const PolicyTableRowComponent = ({ table, isLocked, onSelectToggleRLS = noop, - onSelectCreatePolicy, + onSelectCreatePolicy = noop, onSelectEditPolicy = noop, onSelectDeletePolicy = noop, }: PolicyTableRowProps) => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() + const { getPoliciesForTable, isPoliciesLoading, isPoliciesError, policiesError, exposedSchemas } = + usePoliciesData() + + const policies = useMemo( + () => getPoliciesForTable(table.schema, table.name), + [getPoliciesForTable, table.schema, table.name] + ) // [Joshen] Changes here are so that warnings are more accurate and granular instead of purely relying if RLS is disabled or enabled // The following scenarios are technically okay if the table has RLS disabled, in which it won't be publicly readable / writable @@ -64,10 +62,11 @@ export const PolicyTableRow = ({ // - They only consider the public schema // - They do not consider roles // Eventually if the security lints are able to cover those, we can look to using them as the source of truth instead then - const { data: config } = useProjectPostgrestConfigQuery({ projectRef: project?.ref }) - const exposedSchemas = config?.db_schema ? config?.db_schema.replace(/ /g, '').split(',') : [] const isRLSEnabled = table.rls_enabled - const isTableExposedThroughAPI = exposedSchemas.includes(table.schema) + const isTableExposedThroughAPI = useMemo( + () => exposedSchemas.has(table.schema), + [exposedSchemas, table.schema] + ) const { data: tablesWithAnonAuthAccess = new Set() } = useTablesRolesAccessQuery({ projectRef: project?.ref, @@ -78,19 +77,13 @@ export const PolicyTableRow = ({ const hasAnonAuthenticatedRolesAccess = tablesWithAnonAuthAccess.has(table.name) const isPubliclyReadableWritable = !isRLSEnabled && isTableExposedThroughAPI && hasAnonAuthenticatedRolesAccess - - const { data, error, isLoading, isError, isSuccess } = useDatabasePoliciesQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const policies = (data ?? []) - .filter((policy) => policy.schema === table.schema && policy.table === table.name) - .sort((a, b) => a.name.localeCompare(b.name)) const rlsEnabledNoPolicies = isRLSEnabled && policies.length === 0 const isRealtimeSchema = table.schema === 'realtime' const isRealtimeMessagesTable = isRealtimeSchema && table.name === 'messages' const isTableLocked = isRealtimeSchema ? !isRealtimeMessagesTable : isLocked + const showPolicies = !isPoliciesLoading && !isPoliciesError + return ( )} - {isLoading && ( + {isPoliciesLoading && ( )} - {isError && ( + {isPoliciesError && ( )} - {isSuccess && ( + {showPolicies && ( {policies.length === 0 ? (

No policies created yet

@@ -183,3 +176,6 @@ export const PolicyTableRow = ({
) } + +export const PolicyTableRow = memo(PolicyTableRowComponent) +PolicyTableRow.displayName = 'PolicyTableRow' diff --git a/apps/studio/components/interfaces/Realtime/Policies.tsx b/apps/studio/components/interfaces/Realtime/Policies.tsx index d007130dd2301..9b8afb8cfb354 100644 --- a/apps/studio/components/interfaces/Realtime/Policies.tsx +++ b/apps/studio/components/interfaces/Realtime/Policies.tsx @@ -1,15 +1,19 @@ import { PostgresPolicy } from '@supabase/postgres-meta' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { Policies } from 'components/interfaces/Auth/Policies/Policies' +import { PoliciesDataProvider } from 'components/interfaces/Auth/Policies/PoliciesDataContext' import { PolicyEditorPanel } from 'components/interfaces/Auth/Policies/PolicyEditorPanel' import AlertError from 'components/ui/AlertError' import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useProjectPostgrestConfigQuery } from 'data/config/project-postgrest-config-query' +import { useDatabasePoliciesQuery } from 'data/database-policies/database-policies-query' import { useTablesQuery } from 'data/tables/tables-query' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' export const RealtimePolicies = () => { const { data: project } = useSelectedProjectQuery() + const { data: postgrestConfig } = useProjectPostgrestConfigQuery({ projectRef: project?.ref }) const [showPolicyEditor, setShowPolicyEditor] = useState(false) const [selectedPolicyToEdit, setSelectedPolicyToEdit] = useState() @@ -26,7 +30,32 @@ export const RealtimePolicies = () => { schema: 'realtime', }) - const filteredTables = (tables ?? []).filter((table) => table.name === 'messages') + const filteredTables = useMemo( + () => (tables ?? []).filter((table) => table.name === 'messages'), + [tables] + ) + const visibleTableIds = useMemo( + () => new Set(filteredTables.map((table) => table.id)), + [filteredTables] + ) + const { + data: policies, + isLoading: isLoadingPolicies, + isError: isPoliciesError, + error: policiesError, + } = useDatabasePoliciesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const exposedSchemas = useMemo(() => { + const dbSchema = postgrestConfig?.db_schema + if (!dbSchema) return [] + + return dbSchema + .split(',') + .map((schema) => schema.trim()) + .filter((schema) => schema.length > 0) + }, [postgrestConfig?.db_schema]) return ( <> @@ -35,20 +64,29 @@ export const RealtimePolicies = () => { {isError && } {isSuccess && ( - { - setSelectedPolicyToEdit(undefined) - setShowPolicyEditor(true) - }} - onSelectEditPolicy={(policy) => { - setSelectedPolicyToEdit(policy) - setShowPolicyEditor(true) - }} - /> + + { + setSelectedPolicyToEdit(undefined) + setShowPolicyEditor(true) + }} + onSelectEditPolicy={(policy) => { + setSelectedPolicyToEdit(policy) + setShowPolicyEditor(true) + }} + /> + )} { + const sortedTables = tables.slice().sort((a, b) => a.name.localeCompare(b.name)) + const visibleTableIds = new Set() + if (!searchString) { - return tables.slice().sort((a: PostgresTable, b: PostgresTable) => a.name.localeCompare(b.name)) - } else { - const filter = searchString.toLowerCase() - const findSearchString = (s: string) => s.toLowerCase().includes(filter) - // @ts-ignore Type instantiation is excessively deep and possibly infinite - const filteredPolicies = policies.filter((p: PostgresPolicy) => findSearchString(p.name)) - - return tables - .slice() - .filter((x: PostgresTable) => { - return ( - x.name.toLowerCase().includes(filter) || - x.id.toString() === filter || - filteredPolicies.some((p: PostgresPolicy) => p.table === x.name) - ) - }) - .sort((a: PostgresTable, b: PostgresTable) => a.name.localeCompare(b.name)) + sortedTables.forEach((table) => visibleTableIds.add(table.id)) + return { tables: sortedTables, visibleTableIds } } + + const filter = searchString.toLowerCase() + const matchingPolicyKeys = new Set( + policies + // @ts-ignore Type instantiation is excessively deep and possibly infinite + .filter((policy: PostgresPolicy) => policy.name.toLowerCase().includes(filter)) + .map((policy) => `${policy.schema}.${policy.table}`) + ) + + sortedTables.forEach((table) => { + const matches = + table.name.toLowerCase().includes(filter) || + table.id.toString() === filter || + matchingPolicyKeys.has(`${table.schema}.${table.name}`) + + if (matches) { + visibleTableIds.add(table.id) + } + }) + + return { tables: sortedTables, visibleTableIds } } const AuthPoliciesPage: NextPageWithLayout = () => { - const [params, setParams] = useUrlState<{ - schema?: string - search?: string - }>() - const { schema = 'public', search: searchString = '' } = params + const [schema, setSchema] = useQueryState( + 'schema', + parseAsString.withDefault('public').withOptions({ history: 'replace' }) + ) + const [searchString, setSearchString] = useQueryState( + 'search', + parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true }) + ) + const deferredSearchString = useDeferredValue(searchString) const { data: project } = useSelectedProjectQuery() + const { data: postgrestConfig } = useProjectPostgrestConfigQuery({ projectRef: project?.ref }) const isInlineEditorEnabled = useIsInlineEditorEnabled() const [selectedTable, setSelectedTable] = useState() @@ -81,7 +97,12 @@ const AuthPoliciesPage: NextPageWithLayout = () => { const { isSchemaLocked } = useIsProtectedSchema({ schema: schema, excludedSchemas: ['realtime'] }) - const { data: policies } = useDatabasePoliciesQuery({ + const { + data: policies, + isLoading: isLoadingPolicies, + isError: isPoliciesError, + error: policiesError, + } = useDatabasePoliciesQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) @@ -98,12 +119,50 @@ const AuthPoliciesPage: NextPageWithLayout = () => { schema: schema, }) - const filteredTables = onFilterTables(tables ?? [], policies ?? [], searchString) + const { tables: tablesWithVisibility, visibleTableIds } = useMemo( + () => getTableFilterState(tables ?? [], policies ?? [], searchString), + [tables, policies, searchString] + ) + const exposedSchemas = useMemo(() => { + if (!postgrestConfig?.db_schema) return [] + return postgrestConfig.db_schema + .split(',') + .map((schema) => schema.trim()) + .filter((schema) => schema.length > 0) + }, [postgrestConfig?.db_schema]) const { can: canReadPolicies, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions( PermissionAction.TENANT_SQL_ADMIN_READ, 'policies' ) + const handleSelectCreatePolicy = useCallback( + (table: string) => { + setSelectedTable(table) + setSelectedPolicyToEdit(undefined) + if (isInlineEditorEnabled) { + setEditorPanelOpen(true) + } else { + setShowPolicyAiEditor(true) + } + }, + [isInlineEditorEnabled] + ) + + const handleSelectEditPolicy = useCallback( + (policy: PostgresPolicy) => { + setSelectedPolicyToEdit(policy) + setSelectedTable(undefined) + if (isInlineEditorEnabled) { + setEditorPanelOpen(true) + } else { + setShowPolicyAiEditor(true) + } + }, + [isInlineEditorEnabled] + ) + + const handleResetSearch = useCallback(() => setSearchString(''), [setSearchString]) + if (isPermissionsLoaded && !canReadPolicies) { return } @@ -120,7 +179,7 @@ const AuthPoliciesPage: NextPageWithLayout = () => { value={searchString || ''} onChange={(e) => { const str = e.target.value - setParams({ ...params, search: str === '' ? undefined : str }) + setSearchString(str) }} icon={} /> @@ -130,8 +189,9 @@ const AuthPoliciesPage: NextPageWithLayout = () => { align="end" showError={false} selectedSchemaName={schema} - onSelectSchema={(schema) => { - setParams({ ...params, search: undefined, schema }) + onSelectSchema={(schemaName) => { + setSchema(schemaName) + setSearchString('') }} />
@@ -141,32 +201,25 @@ const AuthPoliciesPage: NextPageWithLayout = () => { {isError && } {isSuccess && ( - 0} - isLocked={isSchemaLocked} - onSelectCreatePolicy={(table: string) => { - setSelectedTable(table) - setSelectedPolicyToEdit(undefined) - if (isInlineEditorEnabled) { - setEditorPanelOpen(true) - } else { - setShowPolicyAiEditor(true) - } - }} - onSelectEditPolicy={(policy) => { - setSelectedPolicyToEdit(policy) - setSelectedTable(undefined) - if (isInlineEditorEnabled) { - setEditorPanelOpen(true) - } else { - setShowPolicyAiEditor(true) - } - }} - onResetSearch={() => setParams({ ...params, search: undefined })} - /> + + 0} + isLocked={isSchemaLocked} + visibleTableIds={visibleTableIds} + onSelectCreatePolicy={handleSelectCreatePolicy} + onSelectEditPolicy={handleSelectEditPolicy} + onResetSearch={handleResetSearch} + /> + )}