diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 397a76cdfb1..2d8ba45fab2 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -576,6 +576,7 @@ export const auth = { { name: 'Managing User Data', url: '/guides/auth/managing-user-data' }, { name: 'Multi-Factor Authentication', url: '/guides/auth/auth-mfa' }, { name: 'Row Level Security', url: '/guides/auth/row-level-security' }, + { name: 'Column Level Security', url: '/guides/auth/column-level-security' }, ], }, { diff --git a/apps/docs/pages/guides/auth/column-level-security.mdx b/apps/docs/pages/guides/auth/column-level-security.mdx new file mode 100644 index 00000000000..e5c22dd79d2 --- /dev/null +++ b/apps/docs/pages/guides/auth/column-level-security.mdx @@ -0,0 +1,158 @@ +import Layout from '~/layouts/DefaultGuideLayout' + +export const meta = { + id: 'column-level-security', + title: 'Column Level Security', + description: 'Secure your data using Postgres Column Level Security.', +} + +PostgreSQL's [Row Level Security (RLS)](https://www.postgresql.org/docs/current/ddl-rowsecurity.html) gives you granular control over who can access rows of data. However, it doesn't give you control over which columns they can access within rows. Sometimes you want to restrict access to specific columns in your database. Column Level Privileges allows you to do just that. + + + This is an advanced feature. We do not recommend using column-level privileges for most users. + Instead, we recommend using RLS policies in combination with a dedicated table for handling user + roles. + + +## Policies at the Row Level + +Policies in Row Level Security (RLS) are used to restrict access to rows in a table. Think of them like adding a `WHERE` clause to every query. + +For example, let's assume you have a `posts` table with the following columns: + +- `id` +- `user_id` +- `title` +- `content` +- `created_at` +- `updated_at` + +You can restrict updates to just the user who created it using [RLS](/docs/guides/auth#row-level-security), with the following policy: + +```sql +create a policy "Allow update for owners" on posts for +update + using (auth.uid () = user_id); +``` + +However, this gives the post owner full access to update the row, including all of the columns. + +## Privileges at the Column Level + +To restrict access to columns, you can use [Privileges](https://www.postgresql.org/docs/current/ddl-priv.html). + +There are two types of privileges in PostgreSQL: + +1. **table-level**: Grants the privilege on all columns in the table. +2. **column-level** Grants the privilege on a specific column in the table. + +You can have both types of privileges on the same table. If you have both, and you revoke the column-level privilege, the table-level privilege will still be in effect. + +By default, our table will have a table-level `UPDATE` privilege, which means that the `authenticated` role can update all the columns in the table. + +```sql +revoke +update + on table public.posts +from + authenticated; + +grant +update + (title, content) on table public.posts to authenticated; +``` + +In the above example, we are revoking the table-level `UPDATE` privilege from the `authenticated` role and granting a column-level `UPDATE` privilege on just the `title` and `content` columns. + +If we want to restrict access to updating the `title` column: + +```sql +revoke +update + (title) on table public.posts +from + authenticated; +``` + +This time, we are revoking the column-level `UPDATE` privilege of the `title` column from the `authenticated` role. We didn't need to revoke the table-level `UPDATE` privilege because it's already revoked. + +## Manage column privileges in the Dashboard + +You can view and edit the privileges in the [Supabase Studio](https://app.supabase.com/project/_/auth/privileges). + +![Column level privileges](/docs/img/guides/privileges/column-level-privileges.png) + +## Manage column privileges in Migrations + +While you can manage privileges directly from the Dashboard, as your project grows you may want to manage them in your migrations. Read about database migrations in the [Local Development](https://supabase.com/docs/guides/getting-started/local-development#database-migrations) guide. + + + + + + + To get started, generate a [new migration](https://supabase.com/docs/reference/cli/supabase-migration-new) to store the SQL needed to create your table along with row and column-level privileges. + + + + + +```bash +supabase migration new create_posts_table +``` + + + + + + + + + + + This creates a new migration: supabase/migrations/\ + _create_posts_table.sql. + + To that file, add the SQL to create this `posts` table with row and column-level privileges. + + + + ```sql + +create table +posts ( +id bigint primary key generated always as identity, +user_id text, +title text, +content text, +created_at timestamptz default now() +updated_at timestamptz default now() +); + +-- Add row-level security +create a policy "Allow update for owners" on posts for +update +using (auth.uid () = user_id); + +-- Add column-level security +revoke +update +(title) on table public.posts +from +authenticated; + +``` + + + + + + +## Considerations when using column-level privileges +- If you turn off a column privilege you won't be able to use that column at all. +- All operations (insert, update, delete) as well as using `select *` will fail. + + +export const Page = ({ children }) => + +export default Page diff --git a/apps/docs/public/img/guides/privileges/column-level-privileges.png b/apps/docs/public/img/guides/privileges/column-level-privileges.png new file mode 100644 index 00000000000..6de359627ca Binary files /dev/null and b/apps/docs/public/img/guides/privileges/column-level-privileges.png differ diff --git a/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx new file mode 100644 index 00000000000..facc947af55 --- /dev/null +++ b/apps/studio/components/interfaces/App/FeaturePreview/CLSPreview.tsx @@ -0,0 +1,56 @@ +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import { BASE_PATH } from 'lib/constants' +import Image from 'next/image' +import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, IconAlertCircle } from 'ui' + +const CLSPreview = () => { + const { ref } = useParams() + + return ( +
+
+ + + + + + Changes to column privileges will not be reflected in migrations when running{' '} + supabase db diff. + + + Column privileges are not supported in the current version of the Supabase CLI. +
+ You will need to manually apply these changes to your database. +
+
+
+ api-docs-side-panel-preview +
+

Enabling this preview will:

+
    +
  • + +
  • +
+
+
+ ) +} + +export default CLSPreview diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index e5c64bf162b..d047d8cdb10 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -21,6 +21,7 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{} [LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT]: false, [LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL]: false, [LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_AI_ASSISTANT]: false, + [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: false, }) useEffect(() => { @@ -32,6 +33,8 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren<{} localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL) === 'true', [LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_AI_ASSISTANT]: localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_AI_ASSISTANT) === 'true', + [LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS]: + localStorage.getItem(LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS) === 'true', }) } }, []) @@ -61,3 +64,8 @@ export const useIsRLSAIAssistantEnabled = () => { const { flags } = useFeaturePreviewContext() return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_AI_ASSISTANT] } + +export const useIsColumnLevelPrivilegesEnabled = () => { + const { flags } = useFeaturePreviewContext() + return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS] +} diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx index 796c6fefdb6..c1dc4066e19 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewModal.tsx @@ -10,6 +10,7 @@ import { LOCAL_STORAGE_KEYS } from 'lib/constants' import Telemetry from 'lib/telemetry' import { useAppStateSnapshot } from 'state/app-state' import APISidePanelPreview from './APISidePanelPreview' +import CLSPreview from './CLSPreview' import { useFeaturePreviewContext } from './FeaturePreviewContext' import RLSAIAssistantPreview from './RLSAIAssistantPreview' @@ -34,6 +35,11 @@ const FeaturePreviewModal = () => { }, ] : []), + { + key: LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS, + name: 'Column-level privileges', + content: , + }, ] const router = useRouter() diff --git a/apps/studio/components/interfaces/Database/Privileges/Privileges.constants.ts b/apps/studio/components/interfaces/Database/Privileges/Privileges.constants.ts new file mode 100644 index 00000000000..3adbd7ff419 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Privileges/Privileges.constants.ts @@ -0,0 +1,8 @@ +export const COLUMN_PRIVILEGE_TYPES = ['SELECT', 'INSERT', 'UPDATE'] as const +export type ColumnPrivilegeType = (typeof COLUMN_PRIVILEGE_TYPES)[number] + +export const TABLE_PRIVILEGE_TYPES = ['DELETE'] as const +export type TablePrivilegeType = (typeof TABLE_PRIVILEGE_TYPES)[number] + +export const ALL_PRIVILEGE_TYPES = [...COLUMN_PRIVILEGE_TYPES, ...TABLE_PRIVILEGE_TYPES] +export type PrivilegeType = ColumnPrivilegeType | TablePrivilegeType diff --git a/apps/studio/components/interfaces/Database/Privileges/Privileges.utils.ts b/apps/studio/components/interfaces/Database/Privileges/Privileges.utils.ts new file mode 100644 index 00000000000..16718b688ca --- /dev/null +++ b/apps/studio/components/interfaces/Database/Privileges/Privileges.utils.ts @@ -0,0 +1,361 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useCallback, useState } from 'react' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { + ColumnPrivilegesGrant, + grantColumnPrivileges, +} from 'data/privileges/column-privileges-grant-mutation' +import { ColumnPrivilege } from 'data/privileges/column-privileges-query' +import { + ColumnPrivilegesRevoke, + revokeColumnPrivileges, +} from 'data/privileges/column-privileges-revoke-mutation' +import { + TablePrivilegesGrant, + grantTablePrivileges, +} from 'data/privileges/table-privileges-grant-mutation' +import { TablePrivilege } from 'data/privileges/table-privileges-query' +import { + TablePrivilegesRevoke, + revokeTablePrivileges, +} from 'data/privileges/table-privileges-revoke-mutation' +import { + ALL_PRIVILEGE_TYPES, + COLUMN_PRIVILEGE_TYPES, + ColumnPrivilegeType, +} from './Privileges.constants' +import { privilegeKeys } from 'data/privileges/keys' + +export interface PrivilegeOperation { + object: 'table' | 'column' + type: 'grant' | 'revoke' + id: string | number + grantee: string + privilege_type: string +} + +export function getDefaultTableCheckedStates(tablePrivilege: TablePrivilege) { + return Object.fromEntries( + ALL_PRIVILEGE_TYPES.map((privilege) => [ + privilege, + tablePrivilege.privileges.find((p) => p.privilege_type === privilege) !== undefined, + ]) + ) +} + +export function getDefaultColumnCheckedStates(columnPrivileges: ColumnPrivilege[]) { + return Object.fromEntries( + columnPrivileges.map((column) => [ + column.column_id, + Object.fromEntries( + COLUMN_PRIVILEGE_TYPES.map((privilege) => [ + privilege, + column.privileges.find((p) => p.privilege_type === privilege) !== undefined, + ]) + ), + ]) + ) +} + +interface UsePrivilegesStateOptions { + tableId: number + role: string + defaultTableCheckedStates: ReturnType + defaultColumnCheckedStates: ReturnType +} + +function addOrRemoveOperation( + operations: PrivilegeOperation[], + operation: PrivilegeOperation, + /** removes old operations and always adds the new one */ + forceAdd = false +): PrivilegeOperation[] { + let state = [...operations] + + const oppositeType = operation.type === 'grant' ? 'revoke' : 'grant' + + const existing = state.find((op) => { + return ( + op.object === operation.object && + op.type === oppositeType && + op.id === operation.id && + op.grantee === operation.grantee && + op.privilege_type === operation.privilege_type + ) + }) + + if (existing !== undefined) { + state = state.filter((op) => op !== existing) + + if (!forceAdd) { + return state + } + } + + state.push(operation) + + return state +} + +export function usePrivilegesState({ + defaultTableCheckedStates, + defaultColumnCheckedStates, + tableId, + role, +}: UsePrivilegesStateOptions) { + const [operations, setOperations] = useState([]) + + const tableCheckedStates = operations.reduce((acc, op) => { + if (op.object === 'table' && op.id === tableId && op.grantee === role) { + return { + ...acc, + [op.privilege_type]: op.type === 'grant', + } + } + + return acc + }, defaultTableCheckedStates) + + const columnCheckedStates = operations.reduce((acc, op) => { + let curr = acc + + if (op.object === 'table' && op.grantee === role) { + curr = Object.fromEntries( + Object.entries(curr).map(([id, column]) => [ + id, + Object.fromEntries( + Object.entries(column).map(([privilege, value]) => [ + privilege, + op.privilege_type === privilege ? op.type === 'grant' : value, + ]) + ), + ]) + ) + } + + if (op.object === 'column' && op.grantee === role) { + return { + ...curr, + [op.id]: { + ...curr[op.id], + [op.privilege_type]: op.type === 'grant', + }, + } + } + + return curr + }, defaultColumnCheckedStates) + + function toggleTablePrivilege(privilegeType: string) { + const shouldGrant = !tableCheckedStates[privilegeType] + + setOperations((prevState) => { + let state = [...prevState] + + if (COLUMN_PRIVILEGE_TYPES.includes(privilegeType as ColumnPrivilegeType)) { + if (shouldGrant) { + // remove all operations for the columns since + // the table privilege will take precedence + state = state.filter( + (op) => + !( + op.object === 'column' && + op.grantee === role && + op.privilege_type === privilegeType + ) + ) + } + } + + state = addOrRemoveOperation(state, { + object: 'table', + type: shouldGrant ? 'grant' : 'revoke', + id: tableId, + grantee: role, + privilege_type: privilegeType, + }) + + return state + }) + } + + function toggleColumnPrivilege(columnId: string, privilegeType: string) { + const shouldGrant = !columnCheckedStates[columnId][privilegeType] + + setOperations((prevState) => { + let state = [...prevState] + + // if the user is revoking a column and the table is enabled + if (!shouldGrant && tableCheckedStates[privilegeType]) { + // also revoke the table privilege + state = addOrRemoveOperation(state, { + object: 'table', + type: 'revoke', + id: tableId, + grantee: role, + privilege_type: privilegeType, + }) + + // grant all other enabled columns + const operations = Object.entries(columnCheckedStates) + .filter(([id]) => id !== columnId) + .map(([id, column]) => ({ + object: 'column' as const, + type: column[privilegeType] ? ('grant' as const) : ('revoke' as const), + id, + grantee: role, + privilege_type: privilegeType, + })) + operations.forEach((op) => { + state = addOrRemoveOperation(state, op) + }) + } + + if (shouldGrant) { + const areAllOtherColumnsEnabled = Object.entries(columnCheckedStates).every( + ([id, column]) => id === columnId || column[privilegeType] + ) + + if (areAllOtherColumnsEnabled) { + // remove all operations for the columns since + // the table privilege will take precedence + state = state.filter( + (op) => + !( + op.object === 'column' && + op.grantee === role && + op.privilege_type === privilegeType + ) + ) + + // grant the table privilege + state = addOrRemoveOperation(state, { + object: 'table', + type: 'grant', + id: tableId, + grantee: role, + privilege_type: privilegeType, + }) + + return state + } + } + + state = addOrRemoveOperation(state, { + object: 'column', + type: shouldGrant ? 'grant' : 'revoke', + id: columnId, + grantee: role, + privilege_type: privilegeType, + }) + + return state + }) + } + + const resetOperations = useCallback(() => { + setOperations([]) + }, []) + + return { + tableCheckedStates, + columnCheckedStates, + operations, + toggleTablePrivilege, + toggleColumnPrivilege, + resetOperations, + } +} + +export function useApplyPrivilegeOperations(callback?: () => void) { + const { project } = useProjectContext() + const queryClient = useQueryClient() + + const [isLoading, setIsLoading] = useState(false) + + const apply = useCallback( + async (operations: PrivilegeOperation[]) => { + if (!project) return console.error('No project selected') + + setIsLoading(true) + + const tableOperations = operations.filter((op) => op.object === 'table') + const columnOperations = operations.filter((op) => op.object === 'column') + + const grantTableOperations = tableOperations + .filter((op) => op.type === 'grant') + .map((op) => ({ + relation_id: Number(op.id), + grantee: op.grantee, + privilege_type: op.privilege_type as TablePrivilegesGrant['privilege_type'], + })) + const revokeTableOperations = tableOperations + .filter((op) => op.type === 'revoke') + .map((op) => ({ + relation_id: Number(op.id), + grantee: op.grantee, + privilege_type: op.privilege_type as TablePrivilegesRevoke['privilege_type'], + })) + + const grantColumnOperations = columnOperations + .filter((op) => op.type === 'grant') + .map((op) => ({ + column_id: String(op.id), + grantee: op.grantee, + privilege_type: op.privilege_type as ColumnPrivilegesGrant['privilege_type'], + })) + const revokeColumnOperations = columnOperations + .filter((op) => op.type === 'revoke') + .map((op) => ({ + column_id: String(op.id), + grantee: op.grantee, + privilege_type: op.privilege_type as ColumnPrivilegesRevoke['privilege_type'], + })) + + // annoyingly these can't be run all at once + // as postgres can't process them in parallel + + if (revokeTableOperations.length > 0) { + await revokeTablePrivileges({ + projectRef: project.ref, + connectionString: project.connectionString, + revokes: revokeTableOperations, + }) + } + if (grantTableOperations.length > 0) { + await grantTablePrivileges({ + projectRef: project.ref, + connectionString: project.connectionString, + grants: grantTableOperations, + }) + } + if (revokeColumnOperations.length > 0) { + await revokeColumnPrivileges({ + projectRef: project.ref, + connectionString: project.connectionString, + revokes: revokeColumnOperations, + }) + } + if (grantColumnOperations.length > 0) { + await grantColumnPrivileges({ + projectRef: project.ref, + connectionString: project.connectionString, + grants: grantColumnOperations, + }) + } + + await Promise.all([ + queryClient.invalidateQueries(privilegeKeys.tablePrivilegesList(project.ref)), + queryClient.invalidateQueries(privilegeKeys.columnPrivilegesList(project.ref)), + ]) + + setIsLoading(false) + + callback?.() + }, + [callback, project, queryClient] + ) + + return { apply, isLoading } +} diff --git a/apps/studio/components/interfaces/Database/Privileges/PrivilegesHead.tsx b/apps/studio/components/interfaces/Database/Privileges/PrivilegesHead.tsx new file mode 100644 index 00000000000..268b497597f --- /dev/null +++ b/apps/studio/components/interfaces/Database/Privileges/PrivilegesHead.tsx @@ -0,0 +1,148 @@ +import { PostgresTable } from '@supabase/postgres-meta' +import SchemaSelector from 'components/ui/SchemaSelector' + +import { + Button, + SelectContent_Shadcn_, + SelectGroup_Shadcn_, + SelectItem_Shadcn_, + SelectTrigger_Shadcn_, + SelectValue_Shadcn_, + Select_Shadcn_, +} from 'ui' + +export interface PrivilegesHeadProps { + disabled: boolean + selectedSchema: string + selectedRole: string + selectedTable?: PostgresTable + tables: string[] + roles: string[] + onChangeSchema: (schema: string) => void + onChangeRole: (role: string) => void + onChangeTable: (table: string) => void + hasChanges?: boolean + resetChanges: () => void + applyChanges: () => void + isApplyingChanges?: boolean +} + +const PrivilegesHead = ({ + disabled, + selectedSchema, + onChangeSchema, + selectedRole, + roles, + onChangeRole, + selectedTable, + tables, + onChangeTable, + hasChanges = false, + resetChanges, + applyChanges, + isApplyingChanges = false, +}: PrivilegesHeadProps) => { + return ( +
+
+ +
+ +
+
+
+ +
+
+ +
+ + +
+
+ ) +} + +const RolesSelect = ({ + selectedRole, + roles, + onChangeRole, +}: { + selectedRole: string + roles: string[] + onChangeRole: (role: string) => void +}) => { + return ( + + + + + + + {roles.map((role) => ( + + role {role} + + ))} + + + + ) +} + +const TablesSelect = ({ + selectedTable, + tables, + onChangeTable, +}: { + selectedTable?: PostgresTable + tables: string[] + onChangeTable: (table: string) => void +}) => { + return ( + + + + + + + {tables.length === 0 ? ( +
+ No tables available in this schema +
+ ) : null} + {tables.map((table) => ( + + table {table} + + ))} +
+
+
+ ) +} + +export default PrivilegesHead diff --git a/apps/studio/components/interfaces/Database/Privileges/PrivilegesTable.tsx b/apps/studio/components/interfaces/Database/Privileges/PrivilegesTable.tsx new file mode 100644 index 00000000000..5eb8543e471 --- /dev/null +++ b/apps/studio/components/interfaces/Database/Privileges/PrivilegesTable.tsx @@ -0,0 +1,114 @@ +import Table from 'components/to-be-cleaned/Table' +import { ColumnPrivilege } from 'data/privileges/column-privileges-query' +import { Switch } from 'ui' +import { + ALL_PRIVILEGE_TYPES, + COLUMN_PRIVILEGE_TYPES, + TABLE_PRIVILEGE_TYPES, +} from './Privileges.constants' +import { usePrivilegesState } from './Privileges.utils' + +export interface PrivilegesTableProps + extends Pick< + ReturnType, + 'tableCheckedStates' | 'columnCheckedStates' | 'toggleTablePrivilege' | 'toggleColumnPrivilege' + > { + columnPrivileges: ColumnPrivilege[] + disabled: boolean + isApplyingChanges?: boolean +} + +const PrivilegesTable = ({ + columnPrivileges, + tableCheckedStates, + columnCheckedStates, + toggleTablePrivilege, + toggleColumnPrivilege, + disabled, + isApplyingChanges = false, +}: PrivilegesTableProps) => { + const handleClickColumnName = (columnId: string) => { + const hasAllPrivileges = COLUMN_PRIVILEGE_TYPES.every( + (privilege) => columnCheckedStates[columnId][privilege] + ) + const privilegesToToggle = COLUMN_PRIVILEGE_TYPES.filter((privilege) => + hasAllPrivileges + ? columnCheckedStates[columnId][privilege] + : !columnCheckedStates[columnId][privilege] + ) + + privilegesToToggle.forEach((privilege) => { + toggleColumnPrivilege(columnId, privilege) + }) + } + + return ( + + Column + , + ...ALL_PRIVILEGE_TYPES.map((privilege) => { + const checked = tableCheckedStates[privilege] + + return ( + +
+ {privilege.charAt(0) + privilege.slice(1).toLowerCase()} + + { + toggleTablePrivilege(privilege) + }} + disabled={disabled || isApplyingChanges} + /> +
+
+ ) + }), + ]} + body={columnPrivileges.map((column) => ( + + + + + {COLUMN_PRIVILEGE_TYPES.map((privilege) => { + const checked = columnCheckedStates[column.column_id][privilege] + + return ( + + {COLUMN_PRIVILEGE_TYPES.includes(privilege as any) && ( +
+ { + toggleColumnPrivilege(column.column_id, privilege) + }} + disabled={disabled || isApplyingChanges} + /> +
+ )} +
+ ) + })} + {TABLE_PRIVILEGE_TYPES.map((privilege) => { + return ( + + N/A + + ) + })} +
+ ))} + /> + ) +} + +export default PrivilegesTable diff --git a/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx b/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx index 29c72835740..68c2a8dcbeb 100644 --- a/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx +++ b/apps/studio/components/interfaces/GraphQL/GraphiQL.tsx @@ -46,6 +46,7 @@ import { } from 'ui' import { RoleImpersonationSelector } from '../RoleImpersonationSelector' import styles from './graphiql.module.css' +import { LOCAL_STORAGE_KEYS } from 'lib/constants' export interface GraphiQLProps { fetcher: Fetcher @@ -84,7 +85,7 @@ const GraphiQLInterface = ({ theme }: GraphiQLInterfaceProps) => { const canReadJWTSecret = useCheckPermissions(PermissionAction.READ, 'field.jwt_secret') const [rlsBypassedWarningDismissed, setRlsBypassedWarningDismissed] = useLocalStorage( - 'graphiql-rls-bypass-warning-dismissed', + LOCAL_STORAGE_KEYS.GRAPHIQL_RLS_BYPASS_WARNING, false ) diff --git a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx index 4eb17c163ed..b4d3ed61bca 100644 --- a/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx +++ b/apps/studio/components/interfaces/SQLEditor/SQLEditor.tsx @@ -26,7 +26,7 @@ import { useSelectedProject, useStore, } from 'hooks' -import { IS_PLATFORM, OPT_IN_TAGS } from 'lib/constants' +import { IS_PLATFORM, LOCAL_STORAGE_KEYS, OPT_IN_TAGS } from 'lib/constants' import { uuidv4 } from 'lib/helpers' import { useProfile } from 'lib/profile' import { wrapWithRoleImpersonation } from 'lib/role-impersonation' @@ -179,7 +179,7 @@ const SQLEditor = () => { const isDiffOpen = !!sqlDiff const [savedSplitSize, setSavedSplitSize] = useLocalStorage( - 'supabase_sql-editor-split-size', + LOCAL_STORAGE_KEYS.SQL_EDITOR_SPLIT_SIZE, `[50, 50]` ) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx index a2d24364450..363acefc6c4 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/ColumnEditor/ColumnEditor.tsx @@ -10,6 +10,7 @@ import { Button, Checkbox, IconAlertCircle, + IconAlertTriangle, IconExternalLink, IconPlus, Input, @@ -391,6 +392,35 @@ const ColumnEditor = ({ + + + {/* TODO: need to pull column privileges in here + if any columns are using column-level privileges, show this warning */} + Column privileges + } + > + + + + This table uses column-privileges + +

+ Several columns in this table have column-level privileges. This new column will + have privileges set to on by default. +

+

+ + + +

+
+
+
+
)} diff --git a/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx b/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx index ed66585ca17..4d3d62e1312 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx +++ b/apps/studio/components/layouts/AuthLayout/AuthLayout.tsx @@ -2,6 +2,7 @@ import { useParams } from 'common' import { useRouter } from 'next/router' import { PropsWithChildren } from 'react' +import { useIsColumnLevelPrivilegesEnabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import ProductMenu from 'components/ui/ProductMenu' import { useAuthConfigPrefetch } from 'data/auth/auth-config-query' import { useFlag, withAuth } from 'hooks' @@ -15,6 +16,7 @@ export interface AuthLayoutProps { const AuthLayout = ({ title, children }: PropsWithChildren) => { const { ref: projectRef = 'default' } = useParams() const hooksReleased = useFlag('authHooksReleased') + const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled() useAuthConfigPrefetch({ projectRef }) @@ -28,7 +30,7 @@ const AuthLayout = ({ title, children }: PropsWithChildren) => productMenu={ } isBlocking={false} diff --git a/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts b/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts index 3ac8204ed17..e3bfdcfc5b4 100644 --- a/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts +++ b/apps/studio/components/layouts/AuthLayout/AuthLayout.utils.ts @@ -3,7 +3,7 @@ import { IS_PLATFORM } from 'lib/constants' export const generateAuthMenu = ( ref: string, - options: { hooksReleased?: boolean } = {} + options: { hooksReleased?: boolean; columnLevelPrivileges?: boolean } = {} ): ProductMenuGroup[] => { return [ { @@ -19,6 +19,17 @@ export const generateAuthMenu = ( url: `/project/${ref}/auth/policies`, items: [], }, + ...(options?.columnLevelPrivileges + ? [ + { + name: 'Column Privileges', + key: 'column-privileges', + url: `/project/${ref}/auth/column-privileges`, + items: [], + label: 'ALPHA', + }, + ] + : []), ...(IS_PLATFORM ? [ { diff --git a/apps/studio/data/privileges/column-privileges-grant-mutation.ts b/apps/studio/data/privileges/column-privileges-grant-mutation.ts new file mode 100644 index 00000000000..d068d9a0cff --- /dev/null +++ b/apps/studio/data/privileges/column-privileges-grant-mutation.ts @@ -0,0 +1,81 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' + +import { components } from 'data/api' +import { post } from 'data/fetchers' +import { ResponseError } from 'types' +import { privilegeKeys } from './keys' + +export type ColumnPrivilegesGrant = components['schemas']['GrantColumnPrivilegesBody'] + +export type ColumnPrivilegesGrantVariables = { + projectRef: string + connectionString?: string + grants: ColumnPrivilegesGrant[] +} + +export async function grantColumnPrivileges({ + projectRef, + connectionString, + grants, +}: ColumnPrivilegesGrantVariables) { + const headers = new Headers() + if (connectionString) { + headers.set('x-connection-encrypted', connectionString) + } + + const { data, error } = await post('/platform/pg-meta/{ref}/column-privileges', { + params: { + path: { + ref: projectRef, + }, + // this is needed to satisfy the typescript, but it doesn't pass the actual header + header: { + 'x-connection-encrypted': connectionString!, + }, + }, + body: grants, + headers, + }) + if (error) { + throw error + } + + return data +} + +type ColumnPrivilegesGrantData = Awaited> + +export const useColumnPrivilegesGrantMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => grantColumnPrivileges(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await Promise.all([ + queryClient.invalidateQueries(privilegeKeys.columnPrivilegesList(projectRef)), + ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to mutate: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/privileges/column-privileges-query.ts b/apps/studio/data/privileges/column-privileges-query.ts new file mode 100644 index 00000000000..e2e69eda20f --- /dev/null +++ b/apps/studio/data/privileges/column-privileges-query.ts @@ -0,0 +1,61 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { get } from 'data/fetchers' +import { ResponseError } from 'types' +import { privilegeKeys } from './keys' +import { components } from 'data/api' + +export type ColumnPrivilegesVariables = { + projectRef?: string + connectionString?: string +} + +export type ColumnPrivilege = components['schemas']['PostgresColumnPrivileges'] + +export async function getColumnPrivileges( + { projectRef, connectionString }: ColumnPrivilegesVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const headers = new Headers() + if (connectionString) { + headers.set('x-connection-encrypted', connectionString) + } + + const { data, error } = await get('/platform/pg-meta/{ref}/column-privileges', { + params: { + path: { + ref: projectRef, + }, + // this is needed to satisfy the typescript, but it doesn't pass the actual header + header: { + 'x-connection-encrypted': connectionString!, + }, + }, + signal, + headers, + }) + if (error) throw error + + return data +} + +export type ColumnPrivilegesData = Awaited> +export type ColumnPrivilegesError = ResponseError + +export const useColumnPrivilegesQuery = ( + { projectRef, connectionString }: ColumnPrivilegesVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + privilegeKeys.columnPrivilegesList(projectRef), + ({ signal }) => getColumnPrivileges({ projectRef, connectionString }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/privileges/column-privileges-revoke-mutation.ts b/apps/studio/data/privileges/column-privileges-revoke-mutation.ts new file mode 100644 index 00000000000..71ebaa7e9b7 --- /dev/null +++ b/apps/studio/data/privileges/column-privileges-revoke-mutation.ts @@ -0,0 +1,81 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' + +import { components } from 'data/api' +import { del } from 'data/fetchers' +import { ResponseError } from 'types' +import { privilegeKeys } from './keys' + +export type ColumnPrivilegesRevoke = components['schemas']['RevokeColumnPrivilegesBody'] + +export type ColumnPrivilegesRevokeVariables = { + projectRef: string + connectionString?: string + revokes: ColumnPrivilegesRevoke[] +} + +export async function revokeColumnPrivileges({ + projectRef, + connectionString, + revokes, +}: ColumnPrivilegesRevokeVariables) { + const headers = new Headers() + if (connectionString) { + headers.set('x-connection-encrypted', connectionString) + } + + const { data, error } = await del('/platform/pg-meta/{ref}/column-privileges', { + params: { + path: { + ref: projectRef, + }, + // this is needed to satisfy the typescript, but it doesn't pass the actual header + header: { + 'x-connection-encrypted': connectionString!, + }, + }, + body: revokes, + headers, + }) + if (error) { + throw error + } + + return data +} + +type ColumnPrivilegesRevokeData = Awaited> + +export const useColumnPrivilegesRevokeMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => revokeColumnPrivileges(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await Promise.all([ + queryClient.invalidateQueries(privilegeKeys.columnPrivilegesList(projectRef)), + ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to mutate: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/privileges/keys.ts b/apps/studio/data/privileges/keys.ts new file mode 100644 index 00000000000..387ddef6dac --- /dev/null +++ b/apps/studio/data/privileges/keys.ts @@ -0,0 +1,6 @@ +export const privilegeKeys = { + tablePrivilegesList: (projectRef: string | undefined) => + [projectRef, 'database', 'table-privileges'] as const, + columnPrivilegesList: (projectRef: string | undefined) => + [projectRef, 'database', 'column-privileges'] as const, +} diff --git a/apps/studio/data/privileges/table-privileges-grant-mutation.ts b/apps/studio/data/privileges/table-privileges-grant-mutation.ts new file mode 100644 index 00000000000..1865b497d13 --- /dev/null +++ b/apps/studio/data/privileges/table-privileges-grant-mutation.ts @@ -0,0 +1,82 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' + +import { components } from 'data/api' +import { post } from 'data/fetchers' +import { ResponseError } from 'types' +import { privilegeKeys } from './keys' + +export type TablePrivilegesGrant = components['schemas']['GrantTablePrivilegesBody'] + +export type TablePrivilegesGrantVariables = { + projectRef: string + connectionString?: string + grants: TablePrivilegesGrant[] +} + +export async function grantTablePrivileges({ + projectRef, + connectionString, + grants, +}: TablePrivilegesGrantVariables) { + const headers = new Headers() + if (connectionString) { + headers.set('x-connection-encrypted', connectionString) + } + + const { data, error } = await post('/platform/pg-meta/{ref}/table-privileges', { + params: { + path: { + ref: projectRef, + }, + // this is needed to satisfy the typescript, but it doesn't pass the actual header + header: { + 'x-connection-encrypted': connectionString!, + }, + }, + body: grants, + headers, + }) + if (error) { + throw error + } + + return data +} + +type TablePrivilegesGrantData = Awaited> + +export const useTablePrivilegesGrantMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => grantTablePrivileges(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await Promise.all([ + queryClient.invalidateQueries(privilegeKeys.tablePrivilegesList(projectRef)), + queryClient.invalidateQueries(privilegeKeys.columnPrivilegesList(projectRef)), + ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to mutate: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/data/privileges/table-privileges-query.ts b/apps/studio/data/privileges/table-privileges-query.ts new file mode 100644 index 00000000000..0d7572f732d --- /dev/null +++ b/apps/studio/data/privileges/table-privileges-query.ts @@ -0,0 +1,61 @@ +import { UseQueryOptions, useQuery } from '@tanstack/react-query' + +import { components } from 'data/api' +import { get } from 'data/fetchers' +import { ResponseError } from 'types' +import { privilegeKeys } from './keys' + +export type TablePrivilegesVariables = { + projectRef?: string + connectionString?: string +} + +export type TablePrivilege = components['schemas']['PostgresTablePrivileges'] + +export async function getTablePrivileges( + { projectRef, connectionString }: TablePrivilegesVariables, + signal?: AbortSignal +) { + if (!projectRef) throw new Error('projectRef is required') + + const headers = new Headers() + if (connectionString) { + headers.set('x-connection-encrypted', connectionString) + } + + const { data, error } = await get('/platform/pg-meta/{ref}/table-privileges', { + params: { + path: { + ref: projectRef, + }, + // this is needed to satisfy the typescript, but it doesn't pass the actual header + header: { + 'x-connection-encrypted': connectionString!, + }, + }, + signal, + headers, + }) + if (error) throw error + + return data +} + +export type TablePrivilegesData = Awaited> +export type TablePrivilegesError = ResponseError + +export const useTablePrivilegesQuery = ( + { projectRef, connectionString }: TablePrivilegesVariables, + { + enabled = true, + ...options + }: UseQueryOptions = {} +) => + useQuery( + privilegeKeys.tablePrivilegesList(projectRef), + ({ signal }) => getTablePrivileges({ projectRef, connectionString }, signal), + { + enabled: enabled && typeof projectRef !== 'undefined', + ...options, + } + ) diff --git a/apps/studio/data/privileges/table-privileges-revoke-mutation.ts b/apps/studio/data/privileges/table-privileges-revoke-mutation.ts new file mode 100644 index 00000000000..337800c5c62 --- /dev/null +++ b/apps/studio/data/privileges/table-privileges-revoke-mutation.ts @@ -0,0 +1,82 @@ +import { useMutation, UseMutationOptions, useQueryClient } from '@tanstack/react-query' +import toast from 'react-hot-toast' + +import { components } from 'data/api' +import { del } from 'data/fetchers' +import { ResponseError } from 'types' +import { privilegeKeys } from './keys' + +export type TablePrivilegesRevoke = components['schemas']['RevokeTablePrivilegesBody'] + +export type TablePrivilegesRevokeVariables = { + projectRef: string + connectionString?: string + revokes: TablePrivilegesRevoke[] +} + +export async function revokeTablePrivileges({ + projectRef, + connectionString, + revokes, +}: TablePrivilegesRevokeVariables) { + const headers = new Headers() + if (connectionString) { + headers.set('x-connection-encrypted', connectionString) + } + + const { data, error } = await del('/platform/pg-meta/{ref}/table-privileges', { + params: { + path: { + ref: projectRef, + }, + // this is needed to satisfy the typescript, but it doesn't pass the actual header + header: { + 'x-connection-encrypted': connectionString!, + }, + }, + body: revokes, + headers, + }) + if (error) { + throw error + } + + return data +} + +type TablePrivilegesRevokeData = Awaited> + +export const useTablePrivilegesRevokeMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation( + (vars) => revokeTablePrivileges(vars), + { + async onSuccess(data, variables, context) { + const { projectRef } = variables + + await Promise.all([ + queryClient.invalidateQueries(privilegeKeys.tablePrivilegesList(projectRef)), + queryClient.invalidateQueries(privilegeKeys.columnPrivilegesList(projectRef)), + ]) + + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to mutate: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + } + ) +} diff --git a/apps/studio/lib/constants/index.ts b/apps/studio/lib/constants/index.ts index 99c99f075f3..6143094166d 100644 --- a/apps/studio/lib/constants/index.ts +++ b/apps/studio/lib/constants/index.ts @@ -33,6 +33,12 @@ export const LOCAL_STORAGE_KEYS = { UI_PREVIEW_API_SIDE_PANEL: 'supabase-ui-api-side-panel', UI_PREVIEW_RLS_AI_ASSISTANT: 'supabase-ui-rls-ai-assistant', DASHBOARD_HISTORY: (ref: string) => `dashboard-history-${ref}`, + UI_PREVIEW_CLS: 'supabase-ui-cls', + + SQL_EDITOR_SPLIT_SIZE: 'supabase_sql-editor-split-size', + GRAPHIQL_RLS_BYPASS_WARNING: 'graphiql-rls-bypass-warning-dismissed', + CLS_DIFF_WARNING: 'cls-diff-warning-dismissed', + CLS_SELECT_STAR_WARNING: 'cls-select-star-warning-dismissed', } export const OPT_IN_TAGS = { diff --git a/apps/studio/lib/local-storage.ts b/apps/studio/lib/local-storage.ts index 878cba9d6ad..cb8b9bf6d5f 100644 --- a/apps/studio/lib/local-storage.ts +++ b/apps/studio/lib/local-storage.ts @@ -1,4 +1,4 @@ -import { LOCAL_STORAGE_KEYS } from 'common' +import { LOCAL_STORAGE_KEYS } from 'lib/constants' const LOCAL_STORAGE_KEYS_ALLOWLIST = [ 'graphiql:theme', @@ -7,6 +7,10 @@ const LOCAL_STORAGE_KEYS_ALLOWLIST = [ 'supabase.dashboard.auth.debug', 'supabase.dashboard.auth.navigatorLock.disabled', LOCAL_STORAGE_KEYS.TELEMETRY_CONSENT, + LOCAL_STORAGE_KEYS.UI_PREVIEW_API_SIDE_PANEL, + LOCAL_STORAGE_KEYS.UI_PREVIEW_NAVIGATION_LAYOUT, + LOCAL_STORAGE_KEYS.UI_PREVIEW_RLS_AI_ASSISTANT, + LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS, ] export function clearLocalStorage() { diff --git a/apps/studio/pages/project/[ref]/auth/column-privileges.tsx b/apps/studio/pages/project/[ref]/auth/column-privileges.tsx new file mode 100644 index 00000000000..973c1e2ae63 --- /dev/null +++ b/apps/studio/pages/project/[ref]/auth/column-privileges.tsx @@ -0,0 +1,380 @@ +import { PostgresRole } from '@supabase/postgres-meta' +import { useParams } from 'common' +import { XIcon } from 'lucide-react' +import Link from 'next/link' +import { useCallback, useMemo, useState } from 'react' +import toast from 'react-hot-toast' +import { + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Alert_Shadcn_, + Button, + IconAlertCircle, + IconExternalLink, +} from 'ui' + +import { useFeaturePreviewContext } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { + getDefaultColumnCheckedStates, + getDefaultTableCheckedStates, + useApplyPrivilegeOperations, + usePrivilegesState, +} from 'components/interfaces/Database/Privileges/Privileges.utils' +import PrivilegesHead from 'components/interfaces/Database/Privileges/PrivilegesHead' +import PrivilegesTable from 'components/interfaces/Database/Privileges/PrivilegesTable' +import ProtectedSchemaWarning from 'components/interfaces/Database/ProtectedSchemaWarning' +import { AuthLayout } from 'components/layouts' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import AlertError from 'components/ui/AlertError' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query' +import { useColumnPrivilegesQuery } from 'data/privileges/column-privileges-query' +import { useTablePrivilegesQuery } from 'data/privileges/table-privileges-query' +import { useTablesQuery } from 'data/tables/tables-query' +import { useLocalStorage } from 'hooks' +import { LOCAL_STORAGE_KEYS } from 'lib/constants' +import { EXCLUDED_SCHEMAS } from 'lib/constants/schemas' +import { NextPageWithLayout } from 'types' +import { useAppStateSnapshot } from 'state/app-state' + +const EDITABLE_ROLES = ['authenticated', 'anon', 'service_role'] + +const PrivilegesPage: NextPageWithLayout = () => { + const { ref, table: paramTable } = useParams() + const { project } = useProjectContext() + const snap = useAppStateSnapshot() + + const featurePreviewContext = useFeaturePreviewContext() + const { flags } = featurePreviewContext + const isEnabled = flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_CLS] + + const [selectedSchema, setSelectedSchema] = useState('public') + const [selectedTable, setSelectedTable] = useState(paramTable) + const [selectedRole, setSelectedRole] = useState('authenticated') + + const { data: tableList, isLoading: isLoadingTables } = useTablesQuery( + { + projectRef: project?.ref, + connectionString: project?.connectionString, + }, + { + onSuccess(data) { + const tables = data + .filter((table) => table.schema === selectedSchema) + .map((table) => table.name) + + if (tables[0] && selectedTable === undefined) { + setSelectedTable(tables[0]) + } + }, + } + ) + + const { data: allRoles, isLoading: isLoadingRoles } = useDatabaseRolesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const tables = tableList + ?.filter((table) => table.schema === selectedSchema) + .map((table) => table.name) + + const { + data: allTablePrivileges, + isLoading: isLoadingTablePrivileges, + isError: isErrorTablePrivileges, + error: errorTablePrivileges, + } = useTablePrivilegesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const tablePrivilege = useMemo(() => { + const tablePrivilege = allTablePrivileges?.find( + (tablePrivilege) => + tablePrivilege.schema === selectedSchema && tablePrivilege.name === selectedTable + ) + + if (tablePrivilege) { + return { + ...tablePrivilege, + privileges: tablePrivilege.privileges.filter( + (privilege) => privilege.grantee === selectedRole + ), + } + } + }, [allTablePrivileges, selectedRole, selectedSchema, selectedTable]) + + const { + data: allColumnPrivileges, + isLoading: isLoadingColumnPrivileges, + isError: isErrorColumnPrivileges, + error: errorColumnPrivileges, + } = useColumnPrivilegesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const columnPrivileges = useMemo( + () => + allColumnPrivileges + ?.filter( + (privilege) => + privilege.relation_schema === selectedSchema && + privilege.relation_name === selectedTable + ) + .map((privilege) => ({ + ...privilege, + privileges: privilege.privileges.filter( + (privilege) => privilege.grantee === selectedRole + ), + })) ?? [], + [allColumnPrivileges, selectedRole, selectedSchema, selectedTable] + ) + + const rolesList = + allRoles?.filter((role: PostgresRole) => EDITABLE_ROLES.includes(role.name)) ?? [] + const roles = rolesList.map((role: PostgresRole) => role.name) + + const table = tableList?.find((table) => table.name === selectedTable) + const isLocked = EXCLUDED_SCHEMAS.includes(selectedSchema) + + const { + tableCheckedStates, + columnCheckedStates, + toggleTablePrivilege, + toggleColumnPrivilege, + operations, + resetOperations, + } = usePrivilegesState( + useMemo( + () => ({ + tableId: table?.id ?? -1, + role: selectedRole, + defaultTableCheckedStates: tablePrivilege + ? getDefaultTableCheckedStates(tablePrivilege) + : {}, + defaultColumnCheckedStates: getDefaultColumnCheckedStates(columnPrivileges), + }), + [columnPrivileges, selectedRole, table?.id, tablePrivilege] + ) + ) + + const hasChanges = operations.length > 0 + + const handleChangeSchema = (schema: string) => { + if (hasChanges) { + if (window.confirm('You will lose your changes. Are you sure?')) { + resetOperations() + } else { + return + } + } + + const newTable = tableList?.find((table) => table.schema === schema)?.name + setSelectedSchema(schema) + setSelectedTable(newTable) + } + + const handleChangeTable = (table: string) => { + if (hasChanges) { + if (window.confirm('You will lose your changes. Are you sure?')) { + resetOperations() + } else { + return + } + } + + setSelectedTable(table) + } + + const handleChangeRole = (role: string) => { + setSelectedRole(role) + } + + const { apply: applyColumnPrivileges, isLoading: isApplyingChanges } = + useApplyPrivilegeOperations( + useCallback(() => { + toast.success( + `Successfully updated privileges on ${selectedSchema}.${selectedTable} for ${selectedRole}`, + { duration: 6000 } + ) + resetOperations() + }, [resetOperations, selectedRole, selectedSchema, selectedTable]) + ) + + function applyChanges() { + applyColumnPrivileges(operations) + } + + const [diffWarningDismissed, setDiffWarningDismissed] = useLocalStorage( + LOCAL_STORAGE_KEYS.CLS_DIFF_WARNING, + false + ) + const [selectStarWarningDismissed, setSelectStarWarningDismissed] = useLocalStorage( + LOCAL_STORAGE_KEYS.CLS_SELECT_STAR_WARNING, + false + ) + + const isLoading = + isLoadingTablePrivileges || isLoadingColumnPrivileges || isLoadingTables || isLoadingRoles + + const isError = isErrorTablePrivileges || isErrorColumnPrivileges + + return ( + + +
+
+
+

Column-level privileges

+
+

Grant or revoke privileges on a column based on user role.

+
+
+ +
+ + {isEnabled ? ( + <> + {!diffWarningDismissed && ( + + + + Changes to column privileges will not be reflected in migrations when running{' '} + supabase db diff. + + + Column privileges are not supported in the current version of the Supabase CLI. +
+ You will need to manually apply these changes to your database. +
+ +
+ )} + + {!selectStarWarningDismissed && ( + + + + Changing column privileges can break existing queries. + + + If you remove a column privilege for a role, that role will lose all access to + that column. +
+ All operations selecting * (including{' '} + returning * for{' '} + insert, update + , and delete) will fail. +
+ +
+ )} + + + {isLocked && ( + + )} + {isLoading ? ( + + ) : isError ? ( + + ) : table && tablePrivilege ? ( +
+ +
+ ) : (tables ?? []).length === 0 ? ( +
+

There are no tables in the {selectedSchema} schema

+

+ Once a table is available in the schema, you may manage it's column-level + privileges here +

+ {selectedSchema === 'public' && ( + + )} +
+ ) : ( +
+

Select a table to edit privileges

+
+ )} + + ) : ( + + + Column-level privileges is a dashboard feature preview + + + You may access this feature by enabling it under dashboard feature previews. + +
+ +
+
+ )} +
+
+
+ ) +} + +PrivilegesPage.getLayout = (page) => {page} + +export default PrivilegesPage diff --git a/apps/studio/public/img/previews/cls-preview.png b/apps/studio/public/img/previews/cls-preview.png new file mode 100644 index 00000000000..85c3d2c8196 Binary files /dev/null and b/apps/studio/public/img/previews/cls-preview.png differ diff --git a/packages/ui/index.tsx b/packages/ui/index.tsx index 4f5425ebc1f..f74e476a520 100644 --- a/packages/ui/index.tsx +++ b/packages/ui/index.tsx @@ -146,6 +146,17 @@ export { AccordionContent as AccordionContent_Shadcn_, } from './src/components/shadcn/ui/accordion' +export { + Select as Select_Shadcn_, + SelectContent as SelectContent_Shadcn_, + SelectGroup as SelectGroup_Shadcn_, + SelectItem as SelectItem_Shadcn_, + SelectLabel as SelectLabel_Shadcn_, + SelectSeparator as SelectSeparator_Shadcn_, + SelectTrigger as SelectTrigger_Shadcn_, + SelectValue as SelectValue_Shadcn_, +} from './src/components/shadcn/ui/select' + export { Input as Input_Shadcn_ } from './src/components/shadcn/ui/input' export { Button as Button_Shadcn_ } from './src/components/shadcn/ui/button' diff --git a/packages/ui/src/components/shadcn/ui/select.tsx b/packages/ui/src/components/shadcn/ui/select.tsx index 3676b9eb0f6..1ae051b310d 100644 --- a/packages/ui/src/components/shadcn/ui/select.tsx +++ b/packages/ui/src/components/shadcn/ui/select.tsx @@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef< (({ className, ...props }, ref) => ( )) @@ -80,7 +80,7 @@ const SelectItem = React.forwardRef< , - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) +const switchRootVariants = cva( + 'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-brand data-[state=checked]:hover:bg-brand-600/90 data-[state=unchecked]:bg-control data-[state=unchecked]:hover:bg-border', + { + variants: { + size: { + small: 'h-[16px] w-[28px]', + medium: 'h-[20px] w-[34px]', + large: 'h-[24px] w-[44px]', + }, + }, + defaultVariants: { + size: 'medium', + }, + } +) + +const switchThumbVariants = cva( + 'pointer-events-none block rounded-full bg-foreground-lighter data-[state=checked]:bg-white shadow-lg ring-0 transition-transform', + { + variants: { + size: { + small: + 'h-[12px] w-[12px] data-[state=checked]:translate-x-[13px] data-[state=unchecked]:translate-x-[1px]', + medium: + 'h-[16px] w-[16px] data-[state=checked]:translate-x-[15px] data-[state=unchecked]:translate-x-[1px]', + large: + 'h-[18px] w-[18px] data-[state=checked]:translate-x-[22px] data-[state=unchecked]:translate-x-[3px]', + }, + }, + defaultVariants: { + size: 'medium', + }, + } +) + +export interface SwitchProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const Switch = React.forwardRef, SwitchProps>( + ({ className, size, ...props }, ref) => ( + + + + ) +) Switch.displayName = SwitchPrimitives.Root.displayName export { Switch }