diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DocsMenu.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DocsMenu.tsx index e514c777660..4de0d5abef3 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/DocsMenu.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/DocsMenu.tsx @@ -75,6 +75,7 @@ export const DocsMenu = ({ ) })} + {group.footer} ))} diff --git a/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx b/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx index 859d733578b..fac8a71615d 100644 --- a/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx +++ b/apps/studio/components/interfaces/Integrations/DataApi/DocsTab.tsx @@ -9,7 +9,11 @@ import { DocsMenu } from '@/components/interfaces/Integrations/DataApi/DocsMenu' import { DocsMobileNav } from '@/components/interfaces/Integrations/DataApi/DocsMobileNav' import { DocView } from '@/components/interfaces/Integrations/DataApi/DocView' import { generateDocsMenu, getActivePage } from '@/components/layouts/DocsLayout/DocsLayout.utils' +import { NotExposedEntitiesIndicator } from '@/components/ui/NotExposedEntitiesIndicator' import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query' +import { partitionExposedDocsEntities } from '@/data/privileges/exposed-docs-entities' +import { useExposedFunctionsQuery } from '@/data/privileges/exposed-functions-query' +import { useExposedTablesQuery } from '@/data/privileges/exposed-tables-query' import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' @@ -27,21 +31,42 @@ export const DataApiDocsTab = () => { const { isEnabled, isPending: isConfigLoading } = useIsDataApiEnabled({ projectRef }) - const { data: openApiSpec } = useOpenAPISpecQuery( - { projectRef }, - { - enabled: !!projectRef && !isPaused && isEnabled, - } + const dataApiEnabled = !!projectRef && !isPaused && isEnabled + + const { data: openApiSpec } = useOpenAPISpecQuery({ projectRef }, { enabled: dataApiEnabled }) + + // Cross-reference the spec against grant status so tables/functions that exist + // in the spec but aren't actually exposed to the Data API are hidden + counted. + const { data: exposedTables } = useExposedTablesQuery( + { projectRef, connectionString: project?.connectionString }, + { enabled: dataApiEnabled } + ) + const { data: exposedFunctions } = useExposedFunctionsQuery( + { projectRef, connectionString: project?.connectionString }, + { enabled: dataApiEnabled } ) - const tableNames = useMemo( - () => (openApiSpec?.tables ?? []).map((table) => table.name), - [openApiSpec] - ) - const functionNames = useMemo( - () => (openApiSpec?.functions ?? []).map((fn) => fn.name), - [openApiSpec] - ) + const { tableNames, excludedTablesCount } = useMemo(() => { + const { visibleEntities, excludedCount } = partitionExposedDocsEntities( + openApiSpec?.tables ?? [], + exposedTables + ) + return { + tableNames: visibleEntities.map((table) => table.name), + excludedTablesCount: excludedCount, + } + }, [openApiSpec?.tables, exposedTables]) + + const { functionNames, excludedFunctionsCount } = useMemo(() => { + const { visibleEntities, excludedCount } = partitionExposedDocsEntities( + openApiSpec?.functions ?? [], + exposedFunctions + ) + return { + functionNames: visibleEntities.map((fn) => fn.name), + excludedFunctionsCount: excludedCount, + } + }, [openApiSpec?.functions, exposedFunctions]) const activePage = useMemo(() => getActivePage({ page, resource, rpc }), [page, resource, rpc]) @@ -49,8 +74,51 @@ export const DataApiDocsTab = () => { const menu = useMemo(() => { if (!projectRef) return [] - return generateDocsMenu(projectRef, tableNames, functionNames, { authEnabled }, docsBasePath) - }, [projectRef, tableNames, functionNames, authEnabled, docsBasePath]) + const groups = generateDocsMenu( + projectRef, + tableNames, + functionNames, + { authEnabled }, + docsBasePath + ) + return groups.map((group) => { + if (group.key === 'tables' && excludedTablesCount > 0) { + return { + ...group, + footer: ( + + ), + } + } + if (group.key === 'functions' && excludedFunctionsCount > 0) { + return { + ...group, + footer: ( + + ), + } + } + return group + }) + }, [ + projectRef, + tableNames, + functionNames, + authEnabled, + docsBasePath, + excludedTablesCount, + excludedFunctionsCount, + ]) if (isConfigLoading) { return ( diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx index 1ac1f02f5bf..1535382868b 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/FirstLevelNav.tsx @@ -8,9 +8,10 @@ import { ShimmeringLoader } from 'ui-patterns' import { navigateToSection } from './Content/Content.utils' import { API_DOCS_CATEGORIES, DOCS_CONTENT, DOCS_MENU } from './ProjectAPIDocs.constants' +import { useApiDocsFunctions, useApiDocsTables } from './useApiDocsEntities' import { InfiniteListDefault, type RowComponentBaseProps } from '@/components/ui/InfiniteList' +import { NotExposedEntitiesIndicator } from '@/components/ui/NotExposedEntitiesIndicator' import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' -import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query' import { usePaginatedBucketsQuery, type Bucket } from '@/data/storage/buckets-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { BASE_PATH, DOCS_URL } from '@/lib/constants' @@ -184,20 +185,15 @@ const Subsections = ({ category }: SubsectionsProps): ReactNode => { } const TablesSubsections = (): ReactNode => { - const { ref } = useParams() const snap = useAppStateSnapshot() - const { data, isLoading } = useOpenAPISpecQuery( - { projectRef: ref }, - { staleTime: 1000 * 60 * 10 } - ) - const tables = data?.tables ?? [] + const { visibleEntities: tables, excludedCount, isLoading } = useApiDocsTables() // TODO: handle infinite loading of tables return ( <> {isLoading && } - {tables.length > 0 && } + {(tables.length > 0 || excludedCount > 0) && } {tables.map((table) => ( ))} + snap.setShowProjectApiDocs(false)} + /> ) } const DbFunctionsSubsections = (): ReactNode => { - const { ref } = useParams() const snap = useAppStateSnapshot() - const { data, isLoading } = useOpenAPISpecQuery( - { projectRef: ref }, - { staleTime: 1000 * 60 * 10 } - ) - const functions = data?.functions ?? [] + const { visibleEntities: functions, excludedCount, isLoading } = useApiDocsFunctions() // TODO: handle virtualization of DB functions return ( <> {isLoading && } - {functions.length > 0 && } + {(functions.length > 0 || excludedCount > 0) && } {functions.map((fn) => ( ))} + snap.setShowProjectApiDocs(false)} + /> ) } diff --git a/apps/studio/components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx b/apps/studio/components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx index e0b3c9d1b7b..2f76da6a8d7 100644 --- a/apps/studio/components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx +++ b/apps/studio/components/interfaces/ProjectAPIDocs/SecondLevelNav.tsx @@ -5,19 +5,14 @@ import { API_DOCS_CATEGORIES } from './ProjectAPIDocs.constants' import { SecondLevelNavLayout, type MenuItemFilter } from './SecondLevelNav.Layout' import { ResourcePickerList } from './SecondLevelNav.ResourcePicker' import { StorageResourceList } from './SecondLevelNav.StoragePicker' +import { useApiDocsFunctions, useApiDocsTables } from './useApiDocsEntities' import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query' -import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query' import { useBucketInfoQueryPreferCached } from '@/data/storage/buckets-query' import { DOCS_URL } from '@/lib/constants' import { useAppStateSnapshot } from '@/state/app-state' -const OPEN_API_SPEC_STALE_TIME = 1000 * 60 * 10 - const EntitiesSecondLevelNav = () => { - const { ref } = useParams() - - const { data } = useOpenAPISpecQuery({ projectRef: ref }, { staleTime: OPEN_API_SPEC_STALE_TIME }) - const tables = data?.tables ?? [] + const { visibleEntities: tables } = useApiDocsTables() return ( { } const StoredProceduresSecondLevelNav = () => { - const { ref } = useParams() - - const { data } = useOpenAPISpecQuery({ projectRef: ref }, { staleTime: OPEN_API_SPEC_STALE_TIME }) - const functions = data?.functions ?? [] + const { visibleEntities: functions } = useApiDocsFunctions() return ( { + const { data: project } = useSelectedProjectQuery() + const projectRef = project?.ref + + const { data: spec, isLoading } = useOpenAPISpecQuery( + { projectRef }, + { staleTime: OPEN_API_SPEC_STALE_TIME } + ) + const { data: exposedTables } = useExposedTablesQuery({ + projectRef, + connectionString: project?.connectionString, + }) + + return useMemo( + () => ({ + ...partitionExposedDocsEntities(spec?.tables ?? [], exposedTables), + isLoading, + }), + [spec?.tables, exposedTables, isLoading] + ) +} + +/** + * Database functions shown in the autogenerated Data API docs, with unexposed + * (revoked) functions filtered out and counted. + */ +export const useApiDocsFunctions = () => { + const { data: project } = useSelectedProjectQuery() + const projectRef = project?.ref + + const { data: spec, isLoading } = useOpenAPISpecQuery( + { projectRef }, + { staleTime: OPEN_API_SPEC_STALE_TIME } + ) + const { data: exposedFunctions } = useExposedFunctionsQuery({ + projectRef, + connectionString: project?.connectionString, + }) + + return useMemo( + () => ({ + ...partitionExposedDocsEntities(spec?.functions ?? [], exposedFunctions), + isLoading, + }), + [spec?.functions, exposedFunctions, isLoading] + ) +} diff --git a/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx b/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx index d37fcf7ce12..a9c7448b3c7 100644 --- a/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx +++ b/apps/studio/components/layouts/DocsLayout/DocsLayout.utils.tsx @@ -48,6 +48,7 @@ export const generateDocsMenu = ( }, { title: 'Tables and Views', + key: 'tables', items: [ { name: 'Introduction', @@ -67,6 +68,7 @@ export const generateDocsMenu = ( }, { title: 'Functions', + key: 'functions', items: [ { name: 'Introduction', diff --git a/apps/studio/components/ui/NotExposedEntitiesIndicator.tsx b/apps/studio/components/ui/NotExposedEntitiesIndicator.tsx new file mode 100644 index 00000000000..428aca44a0a --- /dev/null +++ b/apps/studio/components/ui/NotExposedEntitiesIndicator.tsx @@ -0,0 +1,48 @@ +import { useParams } from 'common' +import Link from 'next/link' +import { type ReactNode } from 'react' +import { cn } from 'ui' + +interface NotExposedEntitiesIndicatorProps { + count: number + /** Singular noun, e.g. "table" or "function" */ + entityNoun: string + /** Plural noun, e.g. "tables" or "functions" */ + entityNounPlural: string + /** Optional callback fired when the link is clicked (e.g. to close a panel) */ + onNavigate?: () => void + /** Overrides the default layout classes (padding/size) — color/hover styling is always applied */ + className?: string +} + +/** + * Quiet link shown beneath a docs entity list when some entities are hidden + * because they aren't exposed to the Data API. Styled like a dimmer nav item and + * links to the Data API settings where the user can grant access. + */ +export const NotExposedEntitiesIndicator = ({ + count, + entityNoun, + entityNounPlural, + onNavigate, + className, +}: NotExposedEntitiesIndicatorProps): ReactNode => { + const { ref } = useParams() + + if (count <= 0) return null + + const noun = count === 1 ? entityNoun : entityNounPlural + + return ( + + {count} {noun} not exposed + + ) +} diff --git a/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts b/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts index 21cf7fd36bf..26b84896e6f 100644 --- a/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts +++ b/apps/studio/components/ui/ProductMenu/ProductMenu.types.ts @@ -10,6 +10,8 @@ export interface ProductMenuGroup { name?: string items: ProductMenuGroupItem[] link?: string + /** Optional node rendered after the group's items (e.g. a footer note) */ + footer?: ReactNode } export interface ProductMenuGroupItem { diff --git a/apps/studio/data/privileges/exposed-docs-entities.test.ts b/apps/studio/data/privileges/exposed-docs-entities.test.ts new file mode 100644 index 00000000000..f31c7d9322d --- /dev/null +++ b/apps/studio/data/privileges/exposed-docs-entities.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest' + +import { partitionExposedDocsEntities, type ExposedEntityStatus } from './exposed-docs-entities' + +type SpecEntity = { name: string } +type StatusItem = { name: string; status: ExposedEntityStatus } + +const spec = (...names: string[]): SpecEntity[] => names.map((name) => ({ name })) +const status = (name: string, status: ExposedEntityStatus): StatusItem => ({ name, status }) + +describe('partitionExposedDocsEntities', () => { + it('fails open when statuses are undefined (not yet loaded)', () => { + const specEntities = spec('todos', 'profiles') + const result = partitionExposedDocsEntities(specEntities, undefined) + expect(result.visibleEntities).toEqual(specEntities) + expect(result.excludedCount).toBe(0) + }) + + it('keeps all entities when every entity is fully granted', () => { + const result = partitionExposedDocsEntities(spec('todos', 'profiles'), [ + status('todos', 'granted'), + status('profiles', 'granted'), + ]) + expect(result.visibleEntities.map((e) => e.name)).toEqual(['todos', 'profiles']) + expect(result.excludedCount).toBe(0) + }) + + it('hides and counts revoked entities', () => { + const result = partitionExposedDocsEntities(spec('todos', 'secret'), [ + status('todos', 'granted'), + status('secret', 'revoked'), + ]) + expect(result.visibleEntities.map((e) => e.name)).toEqual(['todos']) + expect(result.excludedCount).toBe(1) + }) + + it('treats custom (partial) grants as exposed', () => { + const result = partitionExposedDocsEntities(spec('partial'), [status('partial', 'custom')]) + expect(result.visibleEntities.map((e) => e.name)).toEqual(['partial']) + expect(result.excludedCount).toBe(0) + }) + + it('keeps an entity when a same-named entity in another schema is accessible', () => { + // e.g. public.foo (granted) vs private.foo (revoked) — both surface as `foo` + const result = partitionExposedDocsEntities(spec('foo'), [ + status('foo', 'revoked'), + status('foo', 'granted'), + ]) + expect(result.visibleEntities.map((e) => e.name)).toEqual(['foo']) + expect(result.excludedCount).toBe(0) + }) + + it('fails open per-entity when a spec entity has no matching status', () => { + const result = partitionExposedDocsEntities(spec('unknown'), [status('other', 'revoked')]) + expect(result.visibleEntities.map((e) => e.name)).toEqual(['unknown']) + expect(result.excludedCount).toBe(0) + }) + + it('only counts revoked entities that appear in the spec', () => { + const result = partitionExposedDocsEntities(spec('todos'), [ + status('todos', 'granted'), + status('revoked_not_in_spec', 'revoked'), + ]) + expect(result.visibleEntities.map((e) => e.name)).toEqual(['todos']) + expect(result.excludedCount).toBe(0) + }) + + it('handles an empty spec list', () => { + const result = partitionExposedDocsEntities([], [status('todos', 'revoked')]) + expect(result.visibleEntities).toEqual([]) + expect(result.excludedCount).toBe(0) + }) +}) diff --git a/apps/studio/data/privileges/exposed-docs-entities.ts b/apps/studio/data/privileges/exposed-docs-entities.ts new file mode 100644 index 00000000000..37adbb18791 --- /dev/null +++ b/apps/studio/data/privileges/exposed-docs-entities.ts @@ -0,0 +1,55 @@ +export type ExposedEntityStatus = 'granted' | 'revoked' | 'custom' + +type ExposedEntityStatusItem = { + name: string + status: ExposedEntityStatus +} + +export type FilteredDocsEntities = { + visibleEntities: T[] + excludedCount: number +} + +/** + * Splits the entities listed in the PostgREST OpenAPI spec into those that are + * actually accessible via the Data API and those that are not. + * + * An entity is considered exposed when it has at least one API-role grant + * (`granted` or `custom`). It is excluded only when every entry matching its + * name is `revoked` (no API role has any privilege) — mirroring the + * granted/custom/revoked classification used by the Data API settings page. + * + * Fails open: when the grant statuses haven't loaded (or errored), all spec + * entities are returned so the docs are never blanked out. + */ +export function partitionExposedDocsEntities( + specEntities: T[], + exposedStatuses: ExposedEntityStatusItem[] | undefined +): FilteredDocsEntities { + if (!exposedStatuses) { + return { visibleEntities: specEntities, excludedCount: 0 } + } + + const accessibleNames = new Set() + const revokedNames = new Set() + for (const { name, status } of exposedStatuses) { + if (status === 'revoked') { + revokedNames.add(name) + } else { + accessibleNames.add(name) + } + } + + const visibleEntities: T[] = [] + let excludedCount = 0 + for (const entity of specEntities) { + const isExcluded = revokedNames.has(entity.name) && !accessibleNames.has(entity.name) + if (isExcluded) { + excludedCount += 1 + } else { + visibleEntities.push(entity) + } + } + + return { visibleEntities, excludedCount } +} diff --git a/apps/studio/data/privileges/exposed-functions-query.ts b/apps/studio/data/privileges/exposed-functions-query.ts new file mode 100644 index 00000000000..9b42de932af --- /dev/null +++ b/apps/studio/data/privileges/exposed-functions-query.ts @@ -0,0 +1,46 @@ +import { useQuery } from '@tanstack/react-query' + +import { + getExposedFunctions, + type ExposedFunction, + type ExposedFunctionsError, + type ExposedFunctionsVariables, +} from './exposed-functions-infinite-query' +import { privilegeKeys } from './keys' +import type { UseCustomQueryOptions } from '@/types' + +// The API Docs panel renders every function without virtualization, so we fetch +// the full grant-status list in a single request rather than paginating. +const ALL_EXPOSED_ENTITIES_LIMIT = 100_000 + +export type ExposedFunctionsAllData = ExposedFunction[] + +async function getAllExposedFunctions( + { projectRef, connectionString }: ExposedFunctionsVariables, + signal?: AbortSignal +): Promise { + const { functions } = await getExposedFunctions( + { projectRef, connectionString, page: 0, limit: ALL_EXPOSED_ENTITIES_LIMIT }, + signal + ) + return functions +} + +/** + * Returns the grant status (`granted` | `custom` | `revoked`) for every database + * function, used to filter unexposed functions out of the autogenerated Data API + * docs. + */ +export const useExposedFunctionsQuery = ( + { projectRef, connectionString }: ExposedFunctionsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: privilegeKeys.exposedFunctionsAll(projectRef), + queryFn: ({ signal }) => getAllExposedFunctions({ projectRef, connectionString }, signal), + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + }) diff --git a/apps/studio/data/privileges/exposed-tables-query.ts b/apps/studio/data/privileges/exposed-tables-query.ts new file mode 100644 index 00000000000..959d77a70b1 --- /dev/null +++ b/apps/studio/data/privileges/exposed-tables-query.ts @@ -0,0 +1,45 @@ +import { useQuery } from '@tanstack/react-query' + +import { + getExposedTables, + type ExposedTable, + type ExposedTablesError, + type ExposedTablesVariables, +} from './exposed-tables-infinite-query' +import { privilegeKeys } from './keys' +import type { UseCustomQueryOptions } from '@/types' + +// The API Docs panel renders every table without virtualization, so we fetch +// the full grant-status list in a single request rather than paginating. +const ALL_EXPOSED_ENTITIES_LIMIT = 100_000 + +export type ExposedTablesAllData = ExposedTable[] + +async function getAllExposedTables( + { projectRef, connectionString }: ExposedTablesVariables, + signal?: AbortSignal +): Promise { + const { tables } = await getExposedTables( + { projectRef, connectionString, page: 0, limit: ALL_EXPOSED_ENTITIES_LIMIT }, + signal + ) + return tables +} + +/** + * Returns the grant status (`granted` | `custom` | `revoked`) for every table, + * used to filter unexposed tables out of the autogenerated Data API docs. + */ +export const useExposedTablesQuery = ( + { projectRef, connectionString }: ExposedTablesVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: privilegeKeys.exposedTablesAll(projectRef), + queryFn: ({ signal }) => getAllExposedTables({ projectRef, connectionString }, signal), + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + }) diff --git a/apps/studio/data/privileges/keys.ts b/apps/studio/data/privileges/keys.ts index 873cb16e380..da4f5f43084 100644 --- a/apps/studio/data/privileges/keys.ts +++ b/apps/studio/data/privileges/keys.ts @@ -11,6 +11,10 @@ export const privilegeKeys = { 'exposed-tables-infinite', ...(search ? ([{ search }] as const) : []), ] as const, + exposedTablesAll: (projectRef: string | undefined) => + ['projects', projectRef, 'privileges', 'exposed-tables-all'] as const, + exposedFunctionsAll: (projectRef: string | undefined) => + ['projects', projectRef, 'privileges', 'exposed-functions-all'] as const, exposedTableCounts: (projectRef: string | undefined, selectedSchemas?: string[]) => [ 'projects',