policies.tsx411 lines · main
1import { ident, safeSql } from '@supabase/pg-meta'
2import type { PGTable } from '@supabase/pg-meta'
3import { PermissionAction } from '@supabase/shared-types/out/constants'
4import { LOCAL_STORAGE_KEYS, useParams } from 'common'
5import { Search, X } from 'lucide-react'
6import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
7import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
8import { toast } from 'sonner'
9import { Button } from 'ui'
10import { Input } from 'ui-patterns/DataInputs/Input'
11import { PageContainer } from 'ui-patterns/PageContainer'
12import {
13 PageHeader,
14 PageHeaderAside,
15 PageHeaderDescription,
16 PageHeaderMeta,
17 PageHeaderSummary,
18 PageHeaderTitle,
19} from 'ui-patterns/PageHeader'
20import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
21import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
22
23import { useIsInlineEditorEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
24import { useIsRLSTesterEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
25import { Policies } from '@/components/interfaces/Auth/Policies/Policies'
26import { PoliciesDataProvider } from '@/components/interfaces/Auth/Policies/PoliciesDataContext'
27import { getGeneralPolicyTemplates } from '@/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants'
28import { PolicyEditorPanel } from '@/components/interfaces/Auth/Policies/PolicyEditorPanel'
29import {
30 generatePolicyUpdateSQL,
31 type Policy,
32} from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils'
33import { RLSTesterSheet } from '@/components/interfaces/Auth/RLSTester/RLSTesterSheet'
34import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout'
35import { DefaultLayout } from '@/components/layouts/DefaultLayout'
36import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
37import { AlertError } from '@/components/ui/AlertError'
38import { AutoEnableRLSNotice } from '@/components/ui/AutoEnableRLSNotice'
39import { BannerRlsTester } from '@/components/ui/BannerStack/Banners/BannerRlsTester'
40import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
41import { DocsButton } from '@/components/ui/DocsButton'
42import { NoPermission } from '@/components/ui/NoPermission'
43import { SchemaSelector } from '@/components/ui/SchemaSelector'
44import { Shortcut } from '@/components/ui/Shortcut'
45import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
46import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
47import { useTablesQuery } from '@/data/tables/tables-query'
48import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
49import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
50import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
51import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
52import { DOCS_URL } from '@/lib/constants'
53import { onSearchInputEscape } from '@/lib/keyboard'
54import { useEditorPanelStateSnapshot } from '@/state/editor-panel-state'
55import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
56import { useShortcut } from '@/state/shortcuts/useShortcut'
57import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
58import type { NextPageWithLayout } from '@/types'
59
60/**
61 * Filter tables by table name and policy name
62 *
63 * @param tables list of table
64 * @param policies list of policy
65 * @param searchString filter keywords
66 *
67 * @returns list of table
68 */
69const getTableFilterState = (tables: PGTable[], policies: Array<Policy>, searchString?: string) => {
70 const sortedTables = tables.slice().sort((a, b) => a.name.localeCompare(b.name))
71 const visibleTableIds = new Set<number>()
72
73 if (!searchString) {
74 sortedTables.forEach((table) => visibleTableIds.add(table.id))
75 return { tables: sortedTables, visibleTableIds }
76 }
77
78 const filter = searchString.toLowerCase()
79 const matchingPolicyKeys = new Set(
80 policies
81 .filter((policy: Policy) => policy.name.toLowerCase().includes(filter))
82 .map((policy) => `${policy.schema}.${policy.table}`)
83 )
84
85 sortedTables.forEach((table) => {
86 const matches =
87 table.name.toLowerCase().includes(filter) ||
88 table.id.toString() === filter ||
89 matchingPolicyKeys.has(`${table.schema}.${table.name}`)
90
91 if (matches) {
92 visibleTableIds.add(table.id)
93 }
94 })
95
96 return { tables: sortedTables, visibleTableIds }
97}
98
99const AuthPoliciesPage: NextPageWithLayout = () => {
100 const [schema, setSchema] = useQueryState(
101 'schema',
102 parseAsString.withDefault('public').withOptions({ history: 'replace' })
103 )
104 const [searchString, setSearchString] = useQueryState(
105 'search',
106 parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
107 )
108 const deferredSearchString = useDeferredValue(searchString)
109
110 const [selectedIdToEdit, setSelectedIdToEdit] = useQueryState(
111 'edit',
112 parseAsString.withOptions({ history: 'push', clearOnDefault: true })
113 )
114
115 const { ref: projectRef } = useParams()
116 const { data: project } = useSelectedProjectQuery()
117 const { data: postgrestConfig } = useProjectPostgrestConfigQuery({ projectRef: project?.ref })
118
119 const isInlineEditorEnabled = useIsInlineEditorEnabled()
120 const rlsTesterEnabled = useIsRLSTesterEnabled()
121
122 const { openSidebar } = useSidebarManagerSnapshot()
123 const {
124 setValue: setEditorPanelValue,
125 setTemplates: setEditorPanelTemplates,
126 setInitialPrompt: setEditorPanelInitialPrompt,
127 } = useEditorPanelStateSnapshot()
128
129 const [selectedTable, setSelectedTable] = useState<string>()
130 const [showCreatePolicy, setShowCreatePolicy] = useQueryState(
131 'new',
132 parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
133 )
134 const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
135 const searchInputRef = useRef<HTMLInputElement>(null)
136
137 useShortcut(
138 SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
139 () => {
140 searchInputRef.current?.focus()
141 searchInputRef.current?.select()
142 },
143 { label: 'Search policies' }
144 )
145
146 useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => setSearchString(''))
147
148 const { isSchemaLocked } = useIsProtectedSchema({ schema: schema, excludedSchemas: ['realtime'] })
149 const { addBanner, dismissBanner } = useBannerStack()
150
151 const [isAutoEnableRLSMinimized] = useLocalStorageQuery(
152 LOCAL_STORAGE_KEYS.RLS_EVENT_TRIGGER_BANNER_DISMISSED(projectRef ?? ''),
153 false
154 )
155
156 const [isRlsTesterBannerDismissed] = useLocalStorageQuery(
157 LOCAL_STORAGE_KEYS.RLS_TESTER_BANNER_DISMISSED(projectRef ?? ''),
158 false
159 )
160
161 const {
162 data: policies = [],
163 isPending: isLoadingPolicies,
164 isError: isPoliciesError,
165 isSuccess: isPoliciesSuccess,
166 error: policiesError,
167 } = useDatabasePoliciesQuery({
168 projectRef: project?.ref,
169 connectionString: project?.connectionString,
170 })
171 const selectedPolicyToEdit = policies.find((policy) => policy.id.toString() === selectedIdToEdit)
172
173 const {
174 data: tables,
175 isPending: isLoading,
176 isSuccess,
177 isError,
178 error,
179 } = useTablesQuery({
180 projectRef: project?.ref,
181 connectionString: project?.connectionString,
182 schema: schema,
183 })
184
185 const { tables: tablesWithVisibility, visibleTableIds } = useMemo(
186 () => getTableFilterState(tables ?? [], policies ?? [], searchString),
187 [tables, policies, searchString]
188 )
189 const exposedSchemas = useMemo(() => {
190 if (!postgrestConfig?.db_schema) return []
191 return postgrestConfig.db_schema
192 .split(',')
193 .map((schema) => schema.trim())
194 .filter((schema) => schema.length > 0)
195 }, [postgrestConfig?.db_schema])
196
197 const { can: canReadPolicies, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
198 PermissionAction.TENANT_SQL_ADMIN_READ,
199 'policies'
200 )
201
202 const handleSelectCreatePolicy = useCallback(
203 (table: string) => {
204 setSelectedTable(table)
205 setSelectedIdToEdit(null)
206 setShowCreatePolicy(true)
207
208 if (isInlineEditorEnabled) {
209 const defaultSql = safeSql`create policy "replace_with_policy_name"
210 on ${ident(schema)}.${ident(table)}
211 for select
212 to authenticated
213 using (
214 true -- Write your policy condition here
215);`
216
217 setEditorPanelInitialPrompt('Create a new RLS policy that...')
218 setEditorPanelValue(defaultSql)
219 setEditorPanelTemplates([])
220 openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
221 } else {
222 setShowCreatePolicy(true)
223 }
224 },
225 [isInlineEditorEnabled, openSidebar, schema]
226 )
227
228 const handleSelectEditPolicy = useCallback(
229 (policy: Policy) => {
230 setSelectedTable(undefined)
231
232 if (isInlineEditorEnabled) {
233 setEditorPanelInitialPrompt(`Update the RLS policy with name "${policy.name}" that...`)
234 setEditorPanelValue(generatePolicyUpdateSQL(policy))
235 const templates = getGeneralPolicyTemplates(policy.schema, policy.table).map(
236 (template) => ({
237 name: template.templateName,
238 description: template.description,
239 content: template.statement,
240 })
241 )
242 setEditorPanelTemplates(templates)
243 openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
244 } else {
245 setSelectedIdToEdit(policy.id.toString())
246 }
247 },
248 [isInlineEditorEnabled, openSidebar]
249 )
250
251 const handleResetSearch = useCallback(() => setSearchString(''), [setSearchString])
252
253 useEffect(() => {
254 if (rlsTesterEnabled) return
255
256 if (!isRlsTesterBannerDismissed) {
257 addBanner({
258 id: 'rls-tester-banner',
259 isDismissed: false,
260 content: <BannerRlsTester />,
261 priority: 3,
262 })
263 } else {
264 dismissBanner('rls-tester-banner')
265 }
266
267 return () => {
268 dismissBanner('rls-tester-banner')
269 }
270 }, [addBanner, dismissBanner, isRlsTesterBannerDismissed, rlsTesterEnabled])
271
272 useEffect(() => {
273 if (selectedIdToEdit && isPoliciesSuccess && !selectedPolicyToEdit) {
274 toast(`Policy ID ${selectedIdToEdit} cannot be found`)
275 setSelectedIdToEdit(null)
276 }
277 }, [selectedIdToEdit, selectedPolicyToEdit, isPoliciesSuccess, setSelectedIdToEdit])
278
279 const isUpdatingPolicy = !!selectedIdToEdit
280
281 if (isPermissionsLoaded && !canReadPolicies) {
282 return <NoPermission isFullPage resourceText="view this project's RLS policies" />
283 }
284
285 return (
286 <>
287 <PageHeader size="large">
288 <PageHeaderMeta>
289 <PageHeaderSummary>
290 <PageHeaderTitle>Policies</PageHeaderTitle>
291 <PageHeaderDescription>
292 Manage Row Level Security policies for your tables
293 </PageHeaderDescription>
294 </PageHeaderSummary>
295 <PageHeaderAside>
296 {isAutoEnableRLSMinimized && <AutoEnableRLSNotice iconOnly />}
297 <DocsButton href={`${DOCS_URL}/learn/auth-deep-dive/auth-row-level-security`} />
298 {rlsTesterEnabled && <RLSTesterSheet handleSelectEditPolicy={handleSelectEditPolicy} />}
299 </PageHeaderAside>
300 </PageHeaderMeta>
301 </PageHeader>
302
303 <PageContainer size="large">
304 <PageSection>
305 {!isAutoEnableRLSMinimized && <AutoEnableRLSNotice />}
306
307 <PageSectionContent>
308 <div className="mb-4 flex flex-row gap-x-2">
309 <Shortcut
310 id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
311 onTrigger={() => setSchemaSelectorOpen(true)}
312 side="bottom"
313 tooltipOpen={schemaSelectorOpen ? false : undefined}
314 >
315 <SchemaSelector
316 className="w-full lg:w-[180px]"
317 size="tiny"
318 align="end"
319 showError={false}
320 selectedSchemaName={schema}
321 open={schemaSelectorOpen}
322 onOpenChange={setSchemaSelectorOpen}
323 onSelectSchema={(schemaName) => {
324 setSchema(schemaName)
325 setSearchString('')
326 }}
327 />
328 </Shortcut>
329 <Input
330 ref={searchInputRef}
331 size="tiny"
332 placeholder="Filter tables and policies"
333 className="block w-full lg:w-52"
334 containerClassName="[&>div>svg]:-mt-0.5"
335 value={searchString || ''}
336 onChange={(e) => {
337 const str = e.target.value
338 setSearchString(str)
339 }}
340 onKeyDown={onSearchInputEscape(searchString || '', setSearchString)}
341 icon={<Search size={14} />}
342 actions={
343 searchString ? (
344 <Button
345 size="tiny"
346 type="text"
347 className="p-0 h-5 w-5"
348 icon={<X />}
349 onClick={() => setSearchString('')}
350 />
351 ) : null
352 }
353 />
354 </div>
355
356 {isLoading && <GenericSkeletonLoader />}
357
358 {isError && <AlertError error={error} subject="Failed to retrieve tables" />}
359
360 {isSuccess && (
361 <PoliciesDataProvider
362 policies={policies ?? []}
363 isPoliciesLoading={isLoadingPolicies}
364 isPoliciesError={isPoliciesError}
365 policiesError={policiesError ?? undefined}
366 exposedSchemas={exposedSchemas}
367 >
368 <Policies
369 search={deferredSearchString}
370 schema={schema}
371 tables={tablesWithVisibility}
372 hasTables={(tables ?? []).length > 0}
373 isLocked={isSchemaLocked}
374 visibleTableIds={visibleTableIds}
375 onSelectCreatePolicy={handleSelectCreatePolicy}
376 onSelectEditPolicy={handleSelectEditPolicy}
377 onResetSearch={handleResetSearch}
378 />
379 </PoliciesDataProvider>
380 )}
381
382 <PolicyEditorPanel
383 visible={showCreatePolicy || (isUpdatingPolicy && !!selectedPolicyToEdit)}
384 schema={schema}
385 searchString={searchString}
386 selectedTable={isUpdatingPolicy ? undefined : selectedTable}
387 selectedPolicy={isUpdatingPolicy ? selectedPolicyToEdit : undefined}
388 onSelectCancel={() => {
389 setSelectedTable(undefined)
390 if (isUpdatingPolicy) {
391 setSelectedIdToEdit(null)
392 } else {
393 setShowCreatePolicy(false)
394 }
395 }}
396 authContext="database"
397 />
398 </PageSectionContent>
399 </PageSection>
400 </PageContainer>
401 </>
402 )
403}
404
405AuthPoliciesPage.getLayout = (page) => (
406 <DefaultLayout>
407 <AuthLayout title="Policies">{page}</AuthLayout>
408 </DefaultLayout>
409)
410
411export default AuthPoliciesPage