policies.tsx411 lines · main
| 1 | import { ident, safeSql } from '@supabase/pg-meta' |
| 2 | import type { PGTable } from '@supabase/pg-meta' |
| 3 | import { PermissionAction } from '@supabase/shared-types/out/constants' |
| 4 | import { LOCAL_STORAGE_KEYS, useParams } from 'common' |
| 5 | import { Search, X } from 'lucide-react' |
| 6 | import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs' |
| 7 | import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' |
| 8 | import { toast } from 'sonner' |
| 9 | import { Button } from 'ui' |
| 10 | import { Input } from 'ui-patterns/DataInputs/Input' |
| 11 | import { PageContainer } from 'ui-patterns/PageContainer' |
| 12 | import { |
| 13 | PageHeader, |
| 14 | PageHeaderAside, |
| 15 | PageHeaderDescription, |
| 16 | PageHeaderMeta, |
| 17 | PageHeaderSummary, |
| 18 | PageHeaderTitle, |
| 19 | } from 'ui-patterns/PageHeader' |
| 20 | import { PageSection, PageSectionContent } from 'ui-patterns/PageSection' |
| 21 | import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' |
| 22 | |
| 23 | import { useIsInlineEditorEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings' |
| 24 | import { useIsRLSTesterEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' |
| 25 | import { Policies } from '@/components/interfaces/Auth/Policies/Policies' |
| 26 | import { PoliciesDataProvider } from '@/components/interfaces/Auth/Policies/PoliciesDataContext' |
| 27 | import { getGeneralPolicyTemplates } from '@/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants' |
| 28 | import { PolicyEditorPanel } from '@/components/interfaces/Auth/Policies/PolicyEditorPanel' |
| 29 | import { |
| 30 | generatePolicyUpdateSQL, |
| 31 | type Policy, |
| 32 | } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils' |
| 33 | import { RLSTesterSheet } from '@/components/interfaces/Auth/RLSTester/RLSTesterSheet' |
| 34 | import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout' |
| 35 | import { DefaultLayout } from '@/components/layouts/DefaultLayout' |
| 36 | import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' |
| 37 | import { AlertError } from '@/components/ui/AlertError' |
| 38 | import { AutoEnableRLSNotice } from '@/components/ui/AutoEnableRLSNotice' |
| 39 | import { BannerRlsTester } from '@/components/ui/BannerStack/Banners/BannerRlsTester' |
| 40 | import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' |
| 41 | import { DocsButton } from '@/components/ui/DocsButton' |
| 42 | import { NoPermission } from '@/components/ui/NoPermission' |
| 43 | import { SchemaSelector } from '@/components/ui/SchemaSelector' |
| 44 | import { Shortcut } from '@/components/ui/Shortcut' |
| 45 | import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query' |
| 46 | import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query' |
| 47 | import { useTablesQuery } from '@/data/tables/tables-query' |
| 48 | import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' |
| 49 | import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' |
| 50 | import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' |
| 51 | import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas' |
| 52 | import { DOCS_URL } from '@/lib/constants' |
| 53 | import { onSearchInputEscape } from '@/lib/keyboard' |
| 54 | import { useEditorPanelStateSnapshot } from '@/state/editor-panel-state' |
| 55 | import { SHORTCUT_IDS } from '@/state/shortcuts/registry' |
| 56 | import { useShortcut } from '@/state/shortcuts/useShortcut' |
| 57 | import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' |
| 58 | import 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 | */ |
| 69 | const 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 | |
| 99 | const 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 | |
| 405 | AuthPoliciesPage.getLayout = (page) => ( |
| 406 | <DefaultLayout> |
| 407 | <AuthLayout title="Policies">{page}</AuthLayout> |
| 408 | </DefaultLayout> |
| 409 | ) |
| 410 | |
| 411 | export default AuthPoliciesPage |