mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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).
|
||||
|
||||

|
||||
|
||||
## 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]`
|
||||
)
|
||||
|
||||
|
||||
+30
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 |
@@ -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}
|
||||
|
||||
@@ -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 }
|
||||
Reference in new issue
Block a user