feat(dashboard): Add column-level privileges management (#13745)

* add privileges page

* fix

* fix: could have both global and column-specific privileges + is global per privilege

* Trigger Build

* load permissions data for current table only

* fix style for light theme

* add a guide to Privileges

* Update apps/docs/pages/guides/auth/column-level-security.mdx

Co-authored-by: Greg Richardson <greg.nmr@gmail.com>

* Update apps/docs/pages/guides/auth/column-level-security.mdx

Co-authored-by: Greg Richardson <greg.nmr@gmail.com>

* Update doc

* Update docs

* progress moving privileges to pg-meta

* split up column privileges and table privileges

* adds loading state to toggles

* Add feature flag, move from auth section to database section

* Cleanup

* Update meta store table lists, add header warnings, update docs

* Add alert if any columns have privilages

* Spelling

* fix api.d.ts

* fix link

* move to auth pages

* only show editable schemas

* limit roles

* auto select first table

* remove roles meta store

* inline toggling

* apply privileges all at once

* add preview and use new switch component

* fix spelling

* handle long column names

* warn users about losing changes when changing schemas/tables

* remove strange docs link

* fix feature preview after reload

* use shadcn select

* Update select.tsx

* Minor UX fixes

* Update toast

* Update feature preview image for cls

* update cls warnings

* update warnings

* fix toast message

* Add migration alert to preview page

* Add link to CLS from feature preview, and add empty state on CLS page if feature preview is not enabled

* Shift local storage keys hard code to lib/constants

---------

Co-authored-by: Copple <10214025+kiwicopple@users.noreply.github.com>
Co-authored-by: Greg Richardson <greg.nmr@gmail.com>
Co-authored-by: Terry Sutton <saltcod@gmail.com>
Co-authored-by: Alaister Young <a@alaisteryoung.com>
Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
29 files changed
+1819 -28

No files matched your search

@@ -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' },
],
},
{
@@ -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.
<Admonition type="caution">
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.
</Admonition>
## 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.
<StepHikeCompact>
<StepHikeCompact.Step step={1}>
<StepHikeCompact.Details title="Create a migration file">
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.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```bash
supabase migration new create_posts_table
```
</StepHikeCompact.Code>
</StepHikeCompact.Step>
</StepHikeCompact>
<StepHikeCompact>
<StepHikeCompact.Step step={2}>
<StepHikeCompact.Details title="Add the SQL to your migration file">
This creates a new migration: supabase/migrations/\<timestamp\>
_create_posts_table.sql.
To that file, add the SQL to create this `posts` table with row and column-level privileges.
</StepHikeCompact.Details>
<StepHikeCompact.Code>
```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;
```
</StepHikeCompact.Code>
</StepHikeCompact.Step>
</StepHikeCompact>
## 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 }) => <Layout meta={meta} children={children} />
export default Page
Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

@@ -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 (
<div className="flex flex-col gap-2">
<div className="mb-4 flex flex-col gap-y-2">
<Markdown
className="text-foreground-light max-w-full"
content={`[Postgres Column-Level Privileges](https://supabase.com/docs/guides/guides/auth/column-level-security) is a feature of Postgres that allows you to grant or revoke privileges on tables and columns based on user roles.`}
/>
<Markdown
className="text-foreground-light max-w-full"
content={`This is an advanced feature and should be used with caution. Unless you have a very specific use case, we recommend just using [Row-Level Security](https://supabase.com/docs/guides/auth/row-level-security).`}
/>
<Alert_Shadcn_ variant="warning" className="mt-8">
<IconAlertCircle strokeWidth={2} />
<AlertTitle_Shadcn_>
Changes to column privileges will not be reflected in migrations when running{' '}
<code className="text-xs">supabase db diff</code>.
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Column privileges are not supported in the current version of the Supabase CLI.
<br />
You will need to manually apply these changes to your database.
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</div>
<Image
src={`${BASE_PATH}/img/previews/cls-preview.png`}
width={1860}
height={970}
alt="api-docs-side-panel-preview"
className="rounded border"
/>
<div className="space-y-2 !mt-4">
<p className="text-sm">Enabling this preview will:</p>
<ul className="list-disc pl-6 text-sm text-foreground-light space-y-1">
<li>
<Markdown
className="text-foreground-light"
content={`Grant access to a new UI for granting and/or revoking column-level privileges [here](/project/${ref}/auth/column-privileges).`}
/>
</li>
</ul>
</div>
</div>
)
}
export default CLSPreview
@@ -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]
}
@@ -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: <CLSPreview />,
},
]
const router = useRouter()
@@ -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
@@ -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<typeof getDefaultTableCheckedStates>
defaultColumnCheckedStates: ReturnType<typeof getDefaultColumnCheckedStates>
}
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<PrivilegeOperation[]>([])
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 }
}
@@ -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 (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<SchemaSelector
className="bg-control rounded-md w-[200px] [&>button]:py-[5px]"
selectedSchemaName={selectedSchema}
onSelectSchema={onChangeSchema}
/>
<div className="w-[200px]">
<TablesSelect
selectedTable={selectedTable}
tables={tables}
onChangeTable={onChangeTable}
/>
</div>
<div className="h-[20px] w-px border-r border-scale-600"></div>
<div className="w-[200px]">
<RolesSelect selectedRole={selectedRole} roles={roles} onChangeRole={onChangeRole} />
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="default"
size="tiny"
onClick={resetChanges}
disabled={!hasChanges || isApplyingChanges}
>
Reset
</Button>
<Button
type="primary"
size="tiny"
onClick={applyChanges}
disabled={disabled || !hasChanges || isApplyingChanges}
loading={isApplyingChanges}
>
Apply Changes
</Button>
</div>
</div>
)
}
const RolesSelect = ({
selectedRole,
roles,
onChangeRole,
}: {
selectedRole: string
roles: string[]
onChangeRole: (role: string) => void
}) => {
return (
<Select_Shadcn_ value={selectedRole} onValueChange={onChangeRole}>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select a role" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{roles.map((role) => (
<SelectItem_Shadcn_ key={role} value={role}>
<span className="text-foreground-light">role</span> {role}
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
)
}
const TablesSelect = ({
selectedTable,
tables,
onChangeTable,
}: {
selectedTable?: PostgresTable
tables: string[]
onChangeTable: (table: string) => void
}) => {
return (
<Select_Shadcn_ value={selectedTable?.name} onValueChange={onChangeTable}>
<SelectTrigger_Shadcn_>
<SelectValue_Shadcn_ placeholder="Select a table" />
</SelectTrigger_Shadcn_>
<SelectContent_Shadcn_>
<SelectGroup_Shadcn_>
{tables.length === 0 ? (
<div className="text-xs text-foreground-light p-2">
No tables available in this schema
</div>
) : null}
{tables.map((table) => (
<SelectItem_Shadcn_ key={table} value={table}>
<span className="text-foreground-light">table</span> {table}
</SelectItem_Shadcn_>
))}
</SelectGroup_Shadcn_>
</SelectContent_Shadcn_>
</Select_Shadcn_>
)
}
export default PrivilegesHead
@@ -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<typeof usePrivilegesState>,
'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 (
<Table
className="table-fixed mb-4"
head={[
<Table.th key="header-column">
<span>Column</span>
</Table.th>,
...ALL_PRIVILEGE_TYPES.map((privilege) => {
const checked = tableCheckedStates[privilege]
return (
<Table.th key={`header-${privilege}`}>
<div className="inline-flex items-center gap-2">
<span>{privilege.charAt(0) + privilege.slice(1).toLowerCase()}</span>
<Switch
checked={checked}
onCheckedChange={() => {
toggleTablePrivilege(privilege)
}}
disabled={disabled || isApplyingChanges}
/>
</div>
</Table.th>
)
}),
]}
body={columnPrivileges.map((column) => (
<Table.tr key={column.column_id}>
<Table.td>
<button
onClick={() => handleClickColumnName(column.column_id)}
className="block w-full truncate text-left text-foreground"
>
{column.column_name}
</button>
</Table.td>
{COLUMN_PRIVILEGE_TYPES.map((privilege) => {
const checked = columnCheckedStates[column.column_id][privilege]
return (
<Table.td key={privilege}>
{COLUMN_PRIVILEGE_TYPES.includes(privilege as any) && (
<div className="ml-5 inline-flex">
<Switch
checked={checked}
onCheckedChange={() => {
toggleColumnPrivilege(column.column_id, privilege)
}}
disabled={disabled || isApplyingChanges}
/>
</div>
)}
</Table.td>
)
})}
{TABLE_PRIVILEGE_TYPES.map((privilege) => {
return (
<Table.td key={privilege}>
<span className="ml-5 text-xs text-foreground-lighter">N/A</span>
</Table.td>
)
})}
</Table.tr>
))}
/>
)
}
export default PrivilegesTable
@@ -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
)
@@ -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]`
)
@@ -10,6 +10,7 @@ import {
Button,
Checkbox,
IconAlertCircle,
IconAlertTriangle,
IconExternalLink,
IconPlus,
Input,
@@ -391,6 +392,35 @@ const ColumnEditor = ({
</Alert_Shadcn_>
</FormSectionContent>
</FormSection>
<SidePanel.Separator />
{/* TODO: need to pull column privileges in here
if any columns are using column-level privileges, show this warning */}
<FormSection
header={
<FormSectionLabel className="lg:!col-span-4">Column privileges</FormSectionLabel>
}
>
<FormSectionContent loading={false} className="lg:!col-span-8">
<Alert_Shadcn_ variant="warning">
<IconAlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>This table uses column-privileges</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
<p>
Several columns in this table have column-level privileges. This new column will
have privileges set to on by default.
</p>
<p className="mt-3">
<Link href={`/project/${ref}/database/privileges`} passHref>
<Button asChild type="default" size="tiny">
<a>Column-level privileges</a>
</Button>
</Link>
</p>
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
</FormSectionContent>
</FormSection>
</>
)}
@@ -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<AuthLayoutProps>) => {
const { ref: projectRef = 'default' } = useParams()
const hooksReleased = useFlag('authHooksReleased')
const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled()
useAuthConfigPrefetch({ projectRef })
@@ -28,7 +30,7 @@ const AuthLayout = ({ title, children }: PropsWithChildren<AuthLayoutProps>) =>
productMenu={
<ProductMenu
page={page}
menu={generateAuthMenu(projectRef ?? 'default', { hooksReleased })}
menu={generateAuthMenu(projectRef ?? 'default', { hooksReleased, columnLevelPrivileges })}
/>
}
isBlocking={false}
@@ -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
? [
{
@@ -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<ReturnType<typeof grantColumnPrivileges>>
export const useColumnPrivilegesGrantMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<ColumnPrivilegesGrantData, ResponseError, ColumnPrivilegesGrantVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<ColumnPrivilegesGrantData, ResponseError, ColumnPrivilegesGrantVariables>(
(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,
}
)
}
@@ -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<ReturnType<typeof getColumnPrivileges>>
export type ColumnPrivilegesError = ResponseError
export const useColumnPrivilegesQuery = <TData = ColumnPrivilegesData>(
{ projectRef, connectionString }: ColumnPrivilegesVariables,
{
enabled = true,
...options
}: UseQueryOptions<ColumnPrivilegesData, ColumnPrivilegesError, TData> = {}
) =>
useQuery<ColumnPrivilegesData, ColumnPrivilegesError, TData>(
privilegeKeys.columnPrivilegesList(projectRef),
({ signal }) => getColumnPrivileges({ projectRef, connectionString }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined',
...options,
}
)
@@ -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<ReturnType<typeof revokeColumnPrivileges>>
export const useColumnPrivilegesRevokeMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<ColumnPrivilegesRevokeData, ResponseError, ColumnPrivilegesRevokeVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<ColumnPrivilegesRevokeData, ResponseError, ColumnPrivilegesRevokeVariables>(
(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,
}
)
}
+6
View File
@@ -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,
}
@@ -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<ReturnType<typeof grantTablePrivileges>>
export const useTablePrivilegesGrantMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<TablePrivilegesGrantData, ResponseError, TablePrivilegesGrantVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<TablePrivilegesGrantData, ResponseError, TablePrivilegesGrantVariables>(
(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,
}
)
}
@@ -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<ReturnType<typeof getTablePrivileges>>
export type TablePrivilegesError = ResponseError
export const useTablePrivilegesQuery = <TData = TablePrivilegesData>(
{ projectRef, connectionString }: TablePrivilegesVariables,
{
enabled = true,
...options
}: UseQueryOptions<TablePrivilegesData, TablePrivilegesError, TData> = {}
) =>
useQuery<TablePrivilegesData, TablePrivilegesError, TData>(
privilegeKeys.tablePrivilegesList(projectRef),
({ signal }) => getTablePrivileges({ projectRef, connectionString }, signal),
{
enabled: enabled && typeof projectRef !== 'undefined',
...options,
}
)
@@ -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<ReturnType<typeof revokeTablePrivileges>>
export const useTablePrivilegesRevokeMutation = ({
onSuccess,
onError,
...options
}: Omit<
UseMutationOptions<TablePrivilegesRevokeData, ResponseError, TablePrivilegesRevokeVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<TablePrivilegesRevokeData, ResponseError, TablePrivilegesRevokeVariables>(
(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,
}
)
}
+6
View File
@@ -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 = {
+5 -1
View File
@@ -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() {
@@ -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<string>('public')
const [selectedTable, setSelectedTable] = useState<string | undefined>(paramTable)
const [selectedRole, setSelectedRole] = useState<string>('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 (
<ScaffoldContainer className="h-full">
<ScaffoldSection className="h-full">
<div className="col-span-12 flex flex-col pb-4 gap-y-4">
<div className="flex items-center justify-between">
<div>
<h3 className="mb-1 text-xl">Column-level privileges</h3>
<div className="text-sm text-lighter">
<p>Grant or revoke privileges on a column based on user role.</p>
</div>
</div>
<div className="flex items-center space-x-2">
<Button asChild type="default" icon={<IconExternalLink strokeWidth={1.5} />}>
<a
href="https://supabase.com/docs/guides/auth/column-level-security"
target="_blank"
rel="noreferrer"
>
Documentation
</a>
</Button>
</div>
</div>
{isEnabled ? (
<>
{!diffWarningDismissed && (
<Alert_Shadcn_ variant="warning">
<IconAlertCircle strokeWidth={2} />
<AlertTitle_Shadcn_>
Changes to column privileges will not be reflected in migrations when running{' '}
<code className="text-xs">supabase db diff</code>.
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
Column privileges are not supported in the current version of the Supabase CLI.
<br />
You will need to manually apply these changes to your database.
</AlertDescription_Shadcn_>
<Button
type="outline"
aria-label="Dismiss"
className="absolute top-2 right-2 p-1 !pl-1"
onClick={() => {
setDiffWarningDismissed(true)
}}
>
<XIcon width={14} height={14} />
</Button>
</Alert_Shadcn_>
)}
{!selectStarWarningDismissed && (
<Alert_Shadcn_ variant="warning">
<IconAlertCircle strokeWidth={2} />
<AlertTitle_Shadcn_>
Changing column privileges can break existing queries.
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
If you remove a column privilege for a role, that role will lose all access to
that column.
<br />
All operations selecting <code className="text-xs">*</code> (including{' '}
<code className="text-xs">returning *</code> for{' '}
<code className="text-xs">insert</code>, <code className="text-xs">update</code>
, and <code className="text-xs">delete</code>) will fail.
</AlertDescription_Shadcn_>
<Button
type="outline"
aria-label="Dismiss"
className="absolute top-2 right-2 p-1 !pl-1"
onClick={() => {
setSelectStarWarningDismissed(true)
}}
>
<XIcon width={14} height={14} />
</Button>
</Alert_Shadcn_>
)}
<PrivilegesHead
disabled={isLocked}
selectedSchema={selectedSchema}
selectedRole={selectedRole}
selectedTable={table}
tables={tables ?? []}
roles={roles}
onChangeSchema={handleChangeSchema}
onChangeRole={handleChangeRole}
onChangeTable={handleChangeTable}
applyChanges={applyChanges}
resetChanges={resetOperations}
hasChanges={hasChanges}
isApplyingChanges={isApplyingChanges}
/>
{isLocked && (
<ProtectedSchemaWarning schema={selectedSchema} entity="column privileges" />
)}
{isLoading ? (
<GenericSkeletonLoader />
) : isError ? (
<AlertError error={errorTablePrivileges || errorColumnPrivileges} />
) : table && tablePrivilege ? (
<div>
<PrivilegesTable
disabled={isLocked}
columnPrivileges={columnPrivileges}
tableCheckedStates={tableCheckedStates}
columnCheckedStates={columnCheckedStates}
toggleTablePrivilege={toggleTablePrivilege}
toggleColumnPrivilege={toggleColumnPrivilege}
isApplyingChanges={isApplyingChanges}
/>
</div>
) : (tables ?? []).length === 0 ? (
<div className="flex-grow flex flex-col items-center justify-center w-[600px] mx-auto">
<p className="text-center">There are no tables in the {selectedSchema} schema</p>
<p className="text-sm text-foreground-light text-center">
Once a table is available in the schema, you may manage it's column-level
privileges here
</p>
{selectedSchema === 'public' && (
<Button asChild className="mt-4">
<Link href={`/project/${ref}/editor`}>Create a new table</Link>
</Button>
)}
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 ">
<p className="text-foreground-light">Select a table to edit privileges</p>
</div>
)}
</>
) : (
<Alert_Shadcn_>
<AlertTitle_Shadcn_>
Column-level privileges is a dashboard feature preview
</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
You may access this feature by enabling it under dashboard feature previews.
</AlertDescription_Shadcn_>
<div className="mt-4">
<Button type="default" onClick={() => snap.setShowFeaturePreviewModal(true)}>
View feature previews
</Button>
</div>
</Alert_Shadcn_>
)}
</div>
</ScaffoldSection>
</ScaffoldContainer>
)
}
PrivilegesPage.getLayout = (page) => <AuthLayout title="Column Privileges">{page}</AuthLayout>
export default PrivilegesPage
Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

+11
View File
@@ -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'
@@ -19,7 +19,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-control bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-foreground-muted focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex h-8 w-full items-center justify-between rounded-md border border-control bg-control px-3 py-2 text-xs ring-offset-background-control placeholder:text-foreground-muted focus:outline-none ring-border-control focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
@@ -67,7 +67,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pl-8 pr-2 text-sm font-semibold', className)}
className={cn('py-1.5 pl-8 pr-2 text-xs text-foreground-lighter', className)}
{...props}
/>
))
@@ -80,7 +80,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-xs outline-none focus:bg-overlay-hover focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...props}
+51 -19
View File
@@ -2,28 +2,60 @@
import * as React from 'react'
import * as SwitchPrimitives from '@radix-ui/react-switch'
import { VariantProps, cva } from 'class-variance-authority'
import { cn } from '@ui/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-[24px] w-[44px] 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',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-[18px] w-[18px] rounded-full bg-foreground-lighter data-[state=checked]:bg-white shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-[22px] data-[state=unchecked]:translate-x-[3px]'
)}
/>
</SwitchPrimitives.Root>
))
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<typeof SwitchPrimitives.Root>,
VariantProps<typeof switchRootVariants> {}
const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.Root>, SwitchProps>(
({ className, size, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(switchRootVariants({ size }), className)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb className={cn(switchThumbVariants({ size }))} />
</SwitchPrimitives.Root>
)
)
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }