mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 18:11:51 +08:00
## Fixes FE-2619 ## What is the new behavior? This PR adds support for marking table columns as sensitive and masking their values in the grid view. Sensitive columns: - Display an 8-dot mask instead of the underlying value - Remain masked across page refreshes - Can be temporarily revealed for 5 seconds via the **Show data** action - Display a warning when copying rows containing sensitive data This helps prevent accidental exposure of sensitive information when sharing screens, recording demos, or taking screenshots. ## Testing - [x] Toggle sensitivity ON → save → refresh → remains masked - [x] Toggle sensitivity OFF → save → refresh → remains unmasked - [x] Toggle sensitivity multiple times → state remains consistent - [x] Copy row with sensitive columns → warning shown - [x] Click **Show data** → value revealed for 5 seconds then re-masked - [x] Text, Boolean, Binary, JSON, and Foreign Key columns all display a consistent 8-dot mask ### Test data SQL fixture covering multiple PostgreSQL data types: https://gist.github.com/monicakh/2485e9054bf21045912359871e9a1cb4. ### UI <img width="1284" height="554" alt="CleanShot 2026-06-09 at 12 01 33@2x" src="https://github.com/user-attachments/assets/4aec0ba7-c874-42d7-9442-d2c704b319cc" /> <img width="1200" height="560" alt="CleanShot 2026-06-07 at 10 43 40@2x" src="https://github.com/user-attachments/assets/b9569484-6fcc-47de-bc3d-881d0edc4060" /> The **Show data** action is only available for sensitive columns. <img width="450" height="400" alt="CleanShot 2026-06-07 at 10 42 18@2x" src="https://github.com/user-attachments/assets/d48849a2-ec0b-4522-a787-561a1d204ec9" /> Warnings on Copy command <img width="450" height="80" alt="CleanShot 2026-06-09 at 11 58 42@2x" src="https://github.com/user-attachments/assets/374e7d6b-b82a-4923-b035-2ec9b2f7bb7d" /> <img width="450" height="80" alt="CleanShot 2026-06-09 at 11 58 58@2x" src="https://github.com/user-attachments/assets/ecd951bb-e9e2-47ae-9ddd-d32969e01c12" /> <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46180?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: supabase-autofix-bot <noreply@supabase.com> Co-authored-by: Ali Waseem <waseema393@gmail.com>
190 lines
6.6 KiB
TypeScript
190 lines
6.6 KiB
TypeScript
import crypto from 'node:crypto'
|
|
import { expect } from '@playwright/test'
|
|
|
|
import { createTable, dropTable, query } from '../utils/db/index.js'
|
|
import { test } from '../utils/test.js'
|
|
import { toUrl } from '../utils/to-url.js'
|
|
import { waitForGridDataToLoad } from '../utils/wait-for-response.js'
|
|
|
|
const uniqueSuffix = () => crypto.randomBytes(4).toString('hex')
|
|
|
|
const SENSITIVE_MARKER = '[SENSITIVE]'
|
|
const MASKED_VALUE = '••••••••'
|
|
const SECRET_VALUE = 'super_secret_value'
|
|
const PUBLIC_VALUE = 'public_value'
|
|
|
|
/**
|
|
* Creates a table with two columns:
|
|
* - `public_col` (plain text)
|
|
* - `secret_col` (text, comment contains [SENSITIVE] so it is masked by default)
|
|
* Inserts one row so the grid has data to assert against.
|
|
*/
|
|
const setupSensitiveFixture = async () => {
|
|
const suffix = uniqueSuffix()
|
|
const tableName = `pw_sensitive_${suffix}`
|
|
|
|
await createTable(tableName, 'public_col', [{ public_col: PUBLIC_VALUE }])
|
|
await query(`ALTER TABLE public.${tableName} ADD COLUMN secret_col text`, [])
|
|
await query(`COMMENT ON COLUMN public.${tableName}.secret_col IS '${SENSITIVE_MARKER}'`, [])
|
|
await query(`UPDATE public.${tableName} SET secret_col = $1`, [SECRET_VALUE])
|
|
|
|
return {
|
|
tableName,
|
|
async [Symbol.asyncDispose]() {
|
|
await dropTable(tableName)
|
|
},
|
|
}
|
|
}
|
|
|
|
const goToTable = async (
|
|
page: Parameters<typeof waitForGridDataToLoad>[0],
|
|
ref: string,
|
|
tableName: string
|
|
) => {
|
|
await page.goto(toUrl(`/project/${ref}/editor?schema=public`))
|
|
await page.getByRole('button', { name: `View ${tableName}`, exact: true }).click()
|
|
await page.waitForURL(/\/editor\/\d+\?schema=public$/)
|
|
await waitForGridDataToLoad(page, ref)
|
|
}
|
|
|
|
const openColumnMenu = async (
|
|
page: Parameters<typeof waitForGridDataToLoad>[0],
|
|
colName: string
|
|
) => {
|
|
await page
|
|
.getByRole('columnheader', { name: colName })
|
|
.getByRole('button', { name: `Column ${colName} actions` })
|
|
.click()
|
|
}
|
|
|
|
test.describe('table editor — sensitive data masking', () => {
|
|
test('sensitive column shows masked value in grid by default', async ({ page, ref }) => {
|
|
await using fixture = await setupSensitiveFixture()
|
|
await goToTable(page, ref, fixture.tableName)
|
|
|
|
await expect(
|
|
page.getByRole('gridcell', { name: PUBLIC_VALUE }),
|
|
'public column value should be visible'
|
|
).toBeVisible()
|
|
|
|
await expect(
|
|
page.getByRole('gridcell', { name: MASKED_VALUE }),
|
|
'sensitive column should show masked value'
|
|
).toBeVisible()
|
|
|
|
await expect(
|
|
page.getByRole('gridcell', { name: SECRET_VALUE }),
|
|
'actual sensitive value should not be visible'
|
|
).not.toBeVisible()
|
|
})
|
|
|
|
test('column menu shows "Show data" only for sensitive columns', async ({ page, ref }) => {
|
|
await using fixture = await setupSensitiveFixture()
|
|
await goToTable(page, ref, fixture.tableName)
|
|
|
|
await openColumnMenu(page, 'secret_col')
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Show data' }),
|
|
'"Show data" should appear in the menu for a sensitive column'
|
|
).toBeVisible()
|
|
await page.keyboard.press('Escape')
|
|
|
|
await openColumnMenu(page, 'public_col')
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Show data' }),
|
|
'"Show data" should not appear for a non-sensitive column'
|
|
).not.toBeVisible()
|
|
await page.keyboard.press('Escape')
|
|
})
|
|
|
|
test('"Show data" menu item is disabled while column is temporarily revealed', async ({
|
|
page,
|
|
ref,
|
|
}) => {
|
|
await using fixture = await setupSensitiveFixture()
|
|
await goToTable(page, ref, fixture.tableName)
|
|
|
|
// Reveal the sensitive data
|
|
await openColumnMenu(page, 'secret_col')
|
|
await page.getByRole('menuitem', { name: 'Show data' }).click()
|
|
|
|
// Wait for the dropdown to fully unmount before reopening it — a trigger
|
|
// click that lands while the previous menu is still closing is dropped
|
|
await page.getByRole('menu').waitFor({ state: 'detached' })
|
|
|
|
await openColumnMenu(page, 'secret_col')
|
|
|
|
// ColumnMenu.tsx relabels the item to "Data revealed (5s)" while the
|
|
// column is temporarily revealed, so it must be located by that name here
|
|
await expect(
|
|
page.getByRole('menuitem', { name: 'Data revealed (5s)' }),
|
|
'"Show data" menu item should be disabled while column is temporarily revealed'
|
|
).toBeDisabled({ timeout: 2000 })
|
|
|
|
await page.keyboard.press('Escape')
|
|
})
|
|
|
|
test('sensitive data is automatically masked again after 5 seconds', async ({ page, ref }) => {
|
|
await using fixture = await setupSensitiveFixture()
|
|
await goToTable(page, ref, fixture.tableName)
|
|
|
|
// Reveal the sensitive data
|
|
await openColumnMenu(page, 'secret_col')
|
|
await page.getByRole('menuitem', { name: 'Show data' }).click()
|
|
|
|
await expect(
|
|
page.getByRole('gridcell', { name: SECRET_VALUE }),
|
|
'actual value should be visible after reveal'
|
|
).toBeVisible({ timeout: 5000 })
|
|
|
|
// Wait for the temporary reveal to expire
|
|
await page.waitForTimeout(5500)
|
|
|
|
await expect(
|
|
page.getByRole('gridcell', { name: MASKED_VALUE }),
|
|
'sensitive value should be masked again after 5 seconds'
|
|
).toBeVisible()
|
|
|
|
await expect(
|
|
page.getByRole('gridcell', { name: SECRET_VALUE }),
|
|
'actual sensitive value should no longer be visible'
|
|
).not.toBeVisible()
|
|
})
|
|
|
|
test('copying a cell from a sensitive column shows a warning toast', async ({ page, ref }) => {
|
|
await using fixture = await setupSensitiveFixture()
|
|
await goToTable(page, ref, fixture.tableName)
|
|
|
|
// Reveal first so there is actual text in the cell to right-click
|
|
await openColumnMenu(page, 'secret_col')
|
|
await page.getByRole('menuitem', { name: 'Show data' }).click()
|
|
await expect(page.getByRole('gridcell', { name: SECRET_VALUE })).toBeVisible({ timeout: 5000 })
|
|
|
|
const cell = page.getByRole('gridcell', { name: SECRET_VALUE })
|
|
await cell.click({ button: 'right' })
|
|
await page.getByRole('menuitem', { name: 'Copy cell' }).click()
|
|
|
|
await expect(
|
|
page.getByText('Copied sensitive data to clipboard'),
|
|
'warning toast should appear when copying a sensitive cell'
|
|
).toBeVisible({ timeout: 10000 })
|
|
})
|
|
|
|
test('copying a row that contains a sensitive column shows a warning toast', async ({
|
|
page,
|
|
ref,
|
|
}) => {
|
|
await using fixture = await setupSensitiveFixture()
|
|
await goToTable(page, ref, fixture.tableName)
|
|
|
|
const cell = page.getByRole('gridcell', { name: PUBLIC_VALUE })
|
|
await cell.click({ button: 'right' })
|
|
await page.getByRole('menuitem', { name: 'Copy row' }).click()
|
|
|
|
await expect(
|
|
page.getByText('Copied row containing sensitive data to clipboard'),
|
|
'warning toast should appear when copying a row with a sensitive column'
|
|
).toBeVisible({ timeout: 10000 })
|
|
})
|
|
})
|