ProjectNeedsSecuring.tsx194 lines · main
1import { LOCAL_STORAGE_KEYS, useFlag, useParams } from 'common'
2import { AnimatePresence, motion } from 'framer-motion'
3import { useRouter } from 'next/router'
4import { PropsWithChildren, useMemo } from 'react'
5
6import type {
7 ProjectSecurityActionDetails,
8 ProjectSecurityActionType,
9} from './ProjectNeedsSecuring.types'
10import { getExposedSchemas, getTableKey, sortTables } from './ProjectNeedsSecuring.utils'
11import { ProjectNeedsSecuringView } from './ProjectNeedsSecuringView'
12import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
13import { useProjectLintsQuery } from '@/data/lint/lint-query'
14import { useTablePrivilegesQuery } from '@/data/privileges/table-privileges-query'
15import { useTablesQuery } from '@/data/tables/tables-query'
16import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
17import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
18import { isApiAccessRole, isApiPrivilegeType } from '@/lib/data-api-types'
19import { useTrack } from '@/lib/telemetry/track'
20
21const PROJECT_SECURITY_FEATURE_FLAG = 'projectNeedsSecuring'
22const PROJECT_HOME_PATHNAME = '/project/[ref]'
23
24const ProjectNeedsSecuringGate = ({ children }: PropsWithChildren) => {
25 const router = useRouter()
26 const track = useTrack()
27 const { ref: projectRef } = useParams()
28 const { data: project } = useSelectedProjectQuery()
29
30 const [securityDismissedAt, setSecurityDismissedAt, { isLoading: isLoadingDismissedAt }] =
31 useLocalStorageQuery<string | null>(
32 projectRef
33 ? LOCAL_STORAGE_KEYS.PROJECT_SECURITY_DISMISSED_AT(projectRef)
34 : 'project-security-dismissed-at-unknown',
35 null
36 )
37
38 const isProjectHomeRoute = router.pathname === PROJECT_HOME_PATHNAME
39
40 const { data: lints = [], isPending: isLoadingLints } = useProjectLintsQuery(
41 { projectRef },
42 { enabled: isProjectHomeRoute && !!projectRef }
43 )
44
45 const rlsIssueKeys = useMemo(() => {
46 return new Set(
47 lints
48 .filter((lint) => lint.name === 'rls_disabled_in_public' && lint.level === 'ERROR')
49 .map((lint) => {
50 const schema = typeof lint.metadata?.schema === 'string' ? lint.metadata.schema : null
51 const name = typeof lint.metadata?.name === 'string' ? lint.metadata.name : null
52
53 return schema && name ? getTableKey({ schema, name }) : null
54 })
55 .filter((value): value is string => value !== null)
56 )
57 }, [lints])
58
59 const hasRlsIssues = rlsIssueKeys.size > 0
60 const shouldRenderGate =
61 isProjectHomeRoute &&
62 !!projectRef &&
63 !isLoadingDismissedAt &&
64 hasRlsIssues &&
65 securityDismissedAt === null
66
67 const {
68 data: tables,
69 error: tablesError,
70 isPending: isLoadingTables,
71 } = useTablesQuery(
72 {
73 projectRef,
74 connectionString: project?.connectionString,
75 includeColumns: false,
76 },
77 { enabled: shouldRenderGate }
78 )
79
80 const handleTrackAction = (
81 type: ProjectSecurityActionType,
82 details?: ProjectSecurityActionDetails
83 ) => {
84 track('project_security_cta_clicked', {
85 type,
86 ...details,
87 })
88 }
89
90 const {
91 data: dbSchema,
92 error: postgrestConfigError,
93 isPending: isLoadingPostgrestConfig,
94 } = useProjectPostgrestConfigQuery(
95 { projectRef },
96 {
97 enabled: shouldRenderGate,
98 select: ({ db_schema }) => db_schema,
99 }
100 )
101
102 const {
103 data: tablePrivileges,
104 error: tablePrivilegesError,
105 isPending: isLoadingTablePrivileges,
106 } = useTablePrivilegesQuery(
107 { projectRef, connectionString: project?.connectionString },
108 { enabled: shouldRenderGate }
109 )
110
111 const tableRows = useMemo(() => {
112 if (!tables) return []
113
114 const exposedSchemas = getExposedSchemas(dbSchema)
115 const dataApiAccessByTable = new Map<string, boolean>()
116
117 for (const entry of tablePrivileges ?? []) {
118 const key = getTableKey(entry)
119 const hasDataApiAccess = entry.privileges.some(
120 (privilege) =>
121 isApiAccessRole(privilege.grantee) && isApiPrivilegeType(privilege.privilege_type)
122 )
123
124 if (hasDataApiAccess) {
125 dataApiAccessByTable.set(key, true)
126 }
127 }
128
129 return sortTables(
130 tables
131 .filter((table) => exposedSchemas.includes(table.schema))
132 .filter((table) => !table.rls_enabled && rlsIssueKeys.has(getTableKey(table)))
133 .map((table) => {
134 const key = getTableKey(table)
135
136 return {
137 id: table.id,
138 name: table.name,
139 schema: table.schema,
140 rlsEnabled: table.rls_enabled,
141 dataApiAccessible: dataApiAccessByTable.get(key) === true,
142 hasRlsIssue: rlsIssueKeys.has(key),
143 }
144 })
145 )
146 }, [dbSchema, rlsIssueKeys, tablePrivileges, tables])
147
148 if (!isProjectHomeRoute || !projectRef || isLoadingLints || !hasRlsIssues) {
149 return <>{children}</>
150 }
151
152 return (
153 <AnimatePresence mode="wait">
154 {shouldRenderGate ? (
155 <motion.div
156 key="project-needs-securing"
157 className="flex flex-1 min-h-0 w-full"
158 initial={{ opacity: 0 }}
159 animate={{ opacity: 1 }}
160 exit={{ opacity: 0 }}
161 transition={{ duration: 0.2, ease: 'easeOut' }}
162 >
163 <ProjectNeedsSecuringView
164 projectRef={projectRef}
165 issueCount={rlsIssueKeys.size}
166 tables={tableRows}
167 isLoading={isLoadingTables || isLoadingPostgrestConfig || isLoadingTablePrivileges}
168 error={tablesError ?? postgrestConfigError ?? tablePrivilegesError}
169 onDismiss={() => setSecurityDismissedAt(new Date().toISOString())}
170 onTrackAction={handleTrackAction}
171 />
172 </motion.div>
173 ) : (
174 <motion.div
175 key="project-needs-securing-children"
176 className="flex flex-1 min-h-0 w-full"
177 initial={{ opacity: 0 }}
178 animate={{ opacity: 1 }}
179 exit={{ opacity: 0 }}
180 transition={{ duration: 0.2, ease: 'easeOut' }}
181 >
182 {children}
183 </motion.div>
184 )}
185 </AnimatePresence>
186 )
187}
188
189export const ProjectNeedsSecuring = ({ children }: PropsWithChildren) => {
190 const isEnabled = useFlag(PROJECT_SECURITY_FEATURE_FLAG)
191
192 if (!isEnabled) return <>{children}</>
193 return <ProjectNeedsSecuringGate>{children}</ProjectNeedsSecuringGate>
194}