Files
supabase/apps/studio/components/layouts/ProjectNeedsSecuring/ProjectNeedsSecuring.tsx

190 lines
6.0 KiB
TypeScript

import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
import { AnimatePresence, motion } from 'framer-motion'
import { useRouter } from 'next/router'
import { PropsWithChildren, useMemo } from 'react'
import type {
ProjectSecurityActionDetails,
ProjectSecurityActionType,
} from './ProjectNeedsSecuring.types'
import { getExposedSchemas, getTableKey, sortTables } from './ProjectNeedsSecuring.utils'
import { ProjectNeedsSecuringView } from './ProjectNeedsSecuringView'
import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
import { useProjectLintsQuery } from '@/data/lint/lint-query'
import { useTablePrivilegesQuery } from '@/data/privileges/table-privileges-query'
import { useTablesQuery } from '@/data/tables/tables-query'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { isApiAccessRole, isApiPrivilegeType } from '@/lib/data-api-types'
import { useTrack } from '@/lib/telemetry/track'
const PROJECT_SECURITY_FEATURE_FLAG = 'projectNeedsSecuring'
const PROJECT_HOME_PATHNAME = '/project/[ref]'
const ProjectNeedsSecuringGate = ({ children }: PropsWithChildren) => {
const router = useRouter()
const track = useTrack()
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
const [securityDismissedAt, setSecurityDismissedAt, { isLoading: isLoadingDismissedAt }] =
useLocalStorageQuery<string | null>(
projectRef
? LOCAL_STORAGE_KEYS.PROJECT_SECURITY_DISMISSED_AT(projectRef)
: 'project-security-dismissed-at-unknown',
null
)
const isProjectHomeRoute = router.pathname === PROJECT_HOME_PATHNAME
const { data: lints = [] } = useProjectLintsQuery(
{ projectRef },
{ enabled: isProjectHomeRoute && !!projectRef }
)
const rlsIssueKeys = useMemo(() => {
return new Set(
lints
.filter((lint) => lint.name === 'rls_disabled_in_public' && lint.level === 'ERROR')
.map((lint) => {
const schema = typeof lint.metadata?.schema === 'string' ? lint.metadata.schema : null
const name = typeof lint.metadata?.name === 'string' ? lint.metadata.name : null
return schema && name ? getTableKey({ schema, name }) : null
})
.filter((value): value is string => value !== null)
)
}, [lints])
const hasRlsIssues = rlsIssueKeys.size > 0
const shouldRenderGate =
isProjectHomeRoute &&
!!projectRef &&
!isLoadingDismissedAt &&
hasRlsIssues &&
securityDismissedAt === null
const {
data: tables,
error: tablesError,
isPending: isLoadingTables,
} = useTablesQuery(
{
projectRef,
connectionString: project?.connectionString,
includeColumns: false,
},
{ enabled: shouldRenderGate }
)
const handleTrackAction = (
type: ProjectSecurityActionType,
details?: ProjectSecurityActionDetails
) => {
track('project_security_cta_clicked', {
type,
...details,
})
}
const {
data: dbSchema,
error: postgrestConfigError,
isPending: isLoadingPostgrestConfig,
isSuccess: isSuccessPostgrestConfig,
} = useProjectPostgrestConfigQuery(
{ projectRef },
{
enabled: shouldRenderGate,
select: ({ db_schema }) => db_schema,
}
)
const exposedSchemas = getExposedSchemas(dbSchema)
const {
data: tablePrivileges,
error: tablePrivilegesError,
isPending: isLoadingTablePrivileges,
} = useTablePrivilegesQuery(
{ projectRef, connectionString: project?.connectionString, includedSchemas: exposedSchemas },
{ enabled: shouldRenderGate && isSuccessPostgrestConfig }
)
const tableRows = useMemo(() => {
if (!tables) return []
const dataApiAccessByTable = new Map<string, boolean>()
for (const entry of tablePrivileges ?? []) {
const key = getTableKey(entry)
const hasDataApiAccess = entry.privileges.some(
(privilege) =>
isApiAccessRole(privilege.grantee) && isApiPrivilegeType(privilege.privilege_type)
)
if (hasDataApiAccess) {
dataApiAccessByTable.set(key, true)
}
}
return sortTables(
tables
.filter((table) => !table.rls_enabled && rlsIssueKeys.has(getTableKey(table)))
.map((table) => {
const key = getTableKey(table)
return {
id: table.id,
name: table.name,
schema: table.schema,
rlsEnabled: table.rls_enabled,
dataApiAccessible: dataApiAccessByTable.get(key) === true,
hasRlsIssue: rlsIssueKeys.has(key),
}
})
)
}, [rlsIssueKeys, tablePrivileges, tables])
// Keep children in one stable position when not gating. Changing their wrapper
// once lints load used to remount the homepage and reload the charts.
if (!isProjectHomeRoute || !projectRef) {
return <>{children}</>
}
return (
<>
<AnimatePresence>
{shouldRenderGate && (
<motion.div
key="project-needs-securing"
className="flex flex-1 min-h-0 w-full"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
<ProjectNeedsSecuringView
projectRef={projectRef}
issueCount={rlsIssueKeys.size}
tables={tableRows}
isLoading={isLoadingTables || isLoadingPostgrestConfig || isLoadingTablePrivileges}
error={tablesError ?? postgrestConfigError ?? tablePrivilegesError}
onDismiss={() => setSecurityDismissedAt(new Date().toISOString())}
onTrackAction={handleTrackAction}
/>
</motion.div>
)}
</AnimatePresence>
{!shouldRenderGate && children}
</>
)
}
export const ProjectNeedsSecuring = ({ children }: PropsWithChildren) => {
const isEnabled = useFlag(PROJECT_SECURITY_FEATURE_FLAG)
if (!isEnabled) return <>{children}</>
return <ProjectNeedsSecuringGate>{children}</ProjectNeedsSecuringGate>
}