diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 201d1b6dc87..c494d51aea7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -19,4 +19,4 @@ /apps/studio/components/interfaces/Organization/Documents/ @supabase/security /apps/studio/pages/new/index.tsx @supabase/security -/packages/shared-data/compute-disk-limits.ts @supabase/infra +/packages/shared-data/compute-disk-limits.ts @supabase/infra @supabase/platform diff --git a/SECURITY.md b/SECURITY.md deleted file mode 120000 index 65dd29a7e19..00000000000 --- a/SECURITY.md +++ /dev/null @@ -1 +0,0 @@ -apps/docs/public/.well-known/security.txt \ No newline at end of file diff --git a/apps/docs/app/layout.tsx b/apps/docs/app/layout.tsx index aea0eb429ee..c027bf23cfe 100644 --- a/apps/docs/app/layout.tsx +++ b/apps/docs/app/layout.tsx @@ -12,6 +12,8 @@ import { TelemetryTagManager } from 'common' import { genFaviconData } from 'common/MetaFavicons/app-router' import type { Metadata, Viewport } from 'next' +import { inter, manrope } from '@/fonts' + const { metadataApplicationName, metadataTitle } = getCustomContent([ 'metadata:application_name', 'metadata:title', @@ -50,7 +52,7 @@ const viewport: Viewport = { const RootLayout = ({ children }: { children: React.ReactNode }) => { return ( - + diff --git a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts index 013402458e9..ede69d1a1c3 100644 --- a/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts +++ b/apps/docs/components/Navigation/NavigationMenu/NavigationMenu.constants.ts @@ -2096,6 +2096,7 @@ export const storage: NavMenuConstant = { items: [ { name: 'Fundamentals', url: '/guides/storage/cdn/fundamentals' }, { name: 'Smart CDN', url: '/guides/storage/cdn/smart-cdn' }, + { name: 'Purging Cache', url: '/guides/storage/cdn/purge-cdn-cache' }, { name: 'Metrics', url: '/guides/storage/cdn/metrics' }, ], }, diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index 476e64d5a32..4ac0c6866e7 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -48,6 +48,7 @@ const TopNavBar: FC = () => {
Search diff --git a/apps/docs/components/StepHikeCompact/index.tsx b/apps/docs/components/StepHikeCompact/index.tsx index a20e5f8d4bb..11491e5ab0b 100644 --- a/apps/docs/components/StepHikeCompact/index.tsx +++ b/apps/docs/components/StepHikeCompact/index.tsx @@ -80,7 +80,7 @@ const Step: FC> = ({ children, title, step }) => { const Details: FC> = ({ children, title, fullWidth = false }) => { return (
-

{title}

+

{title}

{children}
) diff --git a/apps/docs/content/guides/database/custom-postgres-config.mdx b/apps/docs/content/guides/database/custom-postgres-config.mdx index e690612c664..cfedbc80a5d 100644 --- a/apps/docs/content/guides/database/custom-postgres-config.mdx +++ b/apps/docs/content/guides/database/custom-postgres-config.mdx @@ -119,7 +119,7 @@ Parameters marked with **Restart: Yes** cause the CLI to automatically restart y -Use the examples below with `supabase --experimental --project-ref postgres-config update`: +Use the examples below with `supabase postgres-config update --project-ref --experimental`: | Parameter | Type | Restart | Example | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------- | ------- | --------------------------------------------- | @@ -165,26 +165,26 @@ To start: To update Postgres configurations, use the [`postgres config`](/docs/reference/cli/supabase-postgres-config) command: ```bash -supabase --experimental \ -postgres-config update --config shared_buffers=250MB \ ---project-ref +supabase postgres-config update --config shared_buffers=250MB \ +--project-ref \ +--experimental ``` By default, the CLI will merge any provided config overrides with any existing ones. The `--replace-existing-overrides` flag can be used to instead force all existing overrides to be replaced with the ones being provided: ```bash -supabase --experimental \ -postgres-config update --config max_parallel_workers=3 \ +supabase postgres-config update --config max_parallel_workers=3 \ --replace-existing-overrides \ ---project-ref +--project-ref \ +--experimental ``` To delete specific configuration overrides, use the `postgres-config delete` command: ```bash -supabase --experimental \ -postgres-config delete --config shared_buffers,work_mem \ ---project-ref +supabase postgres-config delete --config shared_buffers,work_mem \ +--project-ref \ +--experimental ``` By default, CLI v2 (≥ 2.0.0) checks the parameter’s context and requests the correct action (reload or restart): @@ -223,9 +223,9 @@ By default, Supabase ensures that this propagation is executed correctly. Howeve ```bash -supabase --experimental \ +supabase postgres-config delete --config shared_buffers --no-restart \ --project-ref \ -postgres-config delete --config shared_buffers --no-restart +--experimental ``` ### Resetting to default config diff --git a/apps/docs/content/guides/database/inspect.mdx b/apps/docs/content/guides/database/inspect.mdx index 490d2f3a3f7..8d01413792c 100644 --- a/apps/docs/content/guides/database/inspect.mdx +++ b/apps/docs/content/guides/database/inspect.mdx @@ -47,7 +47,7 @@ Most inspection commands are Postgres agnostic. You can run inspection routines For example you can connect to your local Postgres instance: ``` -supabase --db-url postgresql://postgres:postgres@localhost:5432/postgres inspect db bloat +supabase inspect db bloat --db-url postgresql://postgres:postgres@localhost:5432/postgres ``` ### Connect to a Supabase instance diff --git a/apps/docs/content/guides/functions/examples/slack-bot-mention.mdx b/apps/docs/content/guides/functions/examples/slack-bot-mention.mdx index 1e8b854fdbe..ea9007623b6 100644 --- a/apps/docs/content/guides/functions/examples/slack-bot-mention.mdx +++ b/apps/docs/content/guides/functions/examples/slack-bot-mention.mdx @@ -20,8 +20,9 @@ For your bot to seamlessly interact with Slack, you'll need to configure Slack A Deploy the following code as an Edge function using the CLI: ```bash -supabase --project-ref nacho_slacker secrets \ -set SLACK_TOKEN= +supabase secrets set \ + SLACK_TOKEN= \ + --project-ref nacho_slacker ``` Here's the code of the Edge Function, you can change the response to handle the text received: diff --git a/apps/docs/content/guides/platform/custom-domains.mdx b/apps/docs/content/guides/platform/custom-domains.mdx index 42173a3fbc9..d0076126fa8 100644 --- a/apps/docs/content/guides/platform/custom-domains.mdx +++ b/apps/docs/content/guides/platform/custom-domains.mdx @@ -168,7 +168,7 @@ Assume your Supabase project's domain is `abcdefghijklmnopqrst.supabase.co` and Use the [`vanity-subdomains check-availability`](/docs/reference/cli/supabase-vanity-subdomains-check-availability) command of the CLI to check if your desired subdomain is available for use: ```bash -supabase vanity-subdomains --project-ref abcdefghijklmnopqrst check-availability --desired-subdomain my-example-brand --experimental +supabase vanity-subdomains check-availability --project-ref abcdefghijklmnopqrst --desired-subdomain my-example-brand --experimental ``` ### Prepare to activate the subdomain @@ -200,7 +200,7 @@ Once you've chosen an available subdomain and have done all the necessary prepar Use the [`vanity-subdomains activate`](/docs/reference/cli/supabase-vanity-subdomains-activate) command to activate and claim your subdomain: ```bash -supabase vanity-subdomains --project-ref abcdefghijklmnopqrst activate --desired-subdomain my-example-brand --experimental +supabase vanity-subdomains activate --project-ref abcdefghijklmnopqrst --desired-subdomain my-example-brand --experimental ``` If you wish to use the new domain in client code, you can set it up like so: diff --git a/apps/docs/content/guides/platform/ssl-enforcement.mdx b/apps/docs/content/guides/platform/ssl-enforcement.mdx index 74249a6da89..2f70acddc3f 100644 --- a/apps/docs/content/guides/platform/ssl-enforcement.mdx +++ b/apps/docs/content/guides/platform/ssl-enforcement.mdx @@ -70,7 +70,7 @@ To get started: You can use the `get` subcommand of the CLI to check whether SSL is currently being enforced: ```bash -supabase ssl-enforcement --project-ref {ref} get --experimental +supabase ssl-enforcement get --project-ref {ref} --experimental ``` Response if SSL is being enforced: @@ -90,13 +90,13 @@ SSL is *NOT* being enforced. The `update` subcommand is used to change the SSL enforcement status for your project: ```bash -supabase ssl-enforcement --project-ref {ref} update --enable-db-ssl-enforcement --experimental +supabase ssl-enforcement update --project-ref {ref} --enable-db-ssl-enforcement --experimental ``` Similarly, to disable SSL enforcement: ```bash -supabase ssl-enforcement --project-ref {ref} update --disable-db-ssl-enforcement --experimental +supabase ssl-enforcement update --project-ref {ref} --disable-db-ssl-enforcement --experimental ``` ### A note about Postgres SSL modes diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index aede885d840..e029e8e2618 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -514,9 +514,10 @@ sh run.sh recreate The `generate-keys.sh` script sets the following secrets automatically. You can also configure them manually in the `.env` file if needed: - `SECRET_KEY_BASE`: encryption key for securing Realtime and Supavisor communications. (Must be at least 64 characters; generate with `openssl rand -base64 48`) +- `REALTIME_DB_ENC_KEY`: encryption key used by Realtime for sensitive fields in the `_realtime` schema. (Must be exactly 16 characters; generate with `openssl rand -hex 8`) - `VAULT_ENC_KEY`: encryption key used by Supavisor for storing encrypted configuration. (Must be exactly 32 characters; generate with `openssl rand -hex 16`) - `PG_META_CRYPTO_KEY`: encryption key for securing connection strings used by Studio against postgres-meta. (Must be at least 32 characters; generate with `openssl rand -base64 24`) -- `LOGFLARE_PUBLIC_ACCESS_TOKEN`: API token for log ingestion and querying. Used by Vector and Studio to send and query logs. (Must be at least 32 characters; generate with `openssl rand -base64 24`) +- `LOGFLARE_PUBLIC_ACCESS_TOKEN`: API token for log ingestion used by Logflare and Vector. (Must be at least 32 characters; generate with `openssl rand -base64 24`) - `LOGFLARE_PRIVATE_ACCESS_TOKEN`: API token for Logflare management operations. Used by Studio for administrative tasks. Never expose client-side. (Must be at least 32 characters; generate with `openssl rand -base64 24`) - `S3_PROTOCOL_ACCESS_KEY_ID`: Access key ID (username-like) for [accessing](/docs/guides/self-hosting/self-hosted-s3) the S3 protocol endpoint in Storage. (Generate with `openssl rand -hex 16`) - `S3_PROTOCOL_ACCESS_KEY_SECRET`: Secret key (password-like) used with S3_PROTOCOL_ACCESS_KEY_ID. (Generate with `openssl rand -hex 32`) diff --git a/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx b/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx index 63552870758..221bf5cb80d 100644 --- a/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx +++ b/apps/docs/content/guides/self-hosting/self-hosted-auth-keys.mdx @@ -61,7 +61,7 @@ storage: -Podman does not support nested variable interpolation (`${A:-${B}}`). If you are using Podman, replace each nested expression with the required variable directly - see the inline comments in `docker-compose.yml` for the exact substitutions. +Nested variable interpolation (`${A:-${B}}`) requires `podman-compose >= 1.6.0`. Earlier versions (still shipped by some Linux distributions) do not support it - if you are on an older `podman-compose`, either upgrade or replace each nested expression with the required variable directly, see the inline comments in `docker-compose.yml` for the exact substitutions. diff --git a/apps/docs/content/guides/storage/cdn/purge-cdn-cache.mdx b/apps/docs/content/guides/storage/cdn/purge-cdn-cache.mdx new file mode 100644 index 00000000000..81627a48985 --- /dev/null +++ b/apps/docs/content/guides/storage/cdn/purge-cdn-cache.mdx @@ -0,0 +1,120 @@ +--- +id: 'storage-cdn-purge-cache' +title: 'Purge CDN Cache' +description: 'Learn how to manually purge Supabase Storage CDN cache.' +sidebar_label: 'CDN' +--- + +With Smart CDN enabled, Supabase Storage automatically invalidates the cache when files are updated or deleted. However, there are scenarios where you may need to manually purge the CDN cache for specific objects or entire buckets. The cache purge API allows you to immediately queue cache content invalidation across all CDN edge nodes. + +Manual cache purging is useful when you need to ensure that updates are propagated as soon as possible, or when you want to clear the cache for debugging purposes. Once purged, the next request for that object will be served from the origin server, and the CDN cache will be repopulated. + + + +Cache purging requires the **secret key**. The server rejects calls made with the legacy anon key or a user JWT. Never expose your secret key in client-side code. + + + + + +CDN cache purge is available for [Pro Plan and above](/pricing). + + + +## Purge a single object + +You can purge the CDN cache for a specific file by providing the exact path to the object. This operation does not support wildcards or recursion. You must specify the complete path of the file you want to invalidate. + + + + +```javascript +import { createClient } from '@supabase/supabase-js' + +// Create Supabase client with secret key +const supabase = createClient('your_project_url', 'your_secret_key') + +// Purge cache for a single object +async function purgeCachedObject() { + const { data, error } = await supabase.storage + .from('bucket_name') + .purgeCache('folder_name/file_name.png') + + if (error) { + // Handle error + } else { + // Handle success + console.log(data.message) // 'success' + } +} +``` + + + + +```bash +curl -X DELETE "https://{your_project_ref}.supabase.co/storage/v1/cdn/bucket_name/folder_name/file_name.png" \ + -H "apikey: {your_secret_key}" + +# If using legacy jwt keys use this header: Authorization: Bearer {your_service_role_jwt} +``` + + + + +## Purge an entire bucket + +For scenarios where you need to invalidate all cached objects in a bucket, you can purge the entire bucket's cache. This is useful when performing bulk updates or major changes to your storage bucket. + + + + +```javascript +import { createClient } from '@supabase/supabase-js' + +// Create Supabase client with secret key +const supabase = createClient('your_project_url', 'your_secret_key') + +// Purge cache for an entire bucket +async function purgeBucketCache() { + const { data, error } = await supabase.storage.purgeBucketCache('bucket_name') + + if (error) { + // Handle error + } else { + // Handle success + console.log(data.message) // 'success' + } +} +``` + + + + +```bash +curl -X DELETE "https://{your_project_ref}.supabase.co/storage/v1/cdn/bucket_name" \ + -H "apikey: {your_secret_key}" + +# If using legacy jwt keys use this header: Authorization: Bearer {your_service_role_jwt} +``` + + + + +## Cache propagation + +After purging the cache, it can take **up to 60 seconds** for the invalidation to propagate across all CDN edge nodes worldwide. During this time, some users may still receive cached content depending on which edge node they are routed to. + +Keep in mind that purging the CDN cache does not affect browser caches. If users have the asset cached locally in their browser, they will continue to see the cached version until the browser cache expires based on the `cacheControl` value set during upload. diff --git a/apps/docs/features/docs/GuidesMdx.template.tsx b/apps/docs/features/docs/GuidesMdx.template.tsx index c3d5a5e2c3c..d46e6636c10 100644 --- a/apps/docs/features/docs/GuidesMdx.template.tsx +++ b/apps/docs/features/docs/GuidesMdx.template.tsx @@ -101,9 +101,9 @@ const GuideTemplate = ({ {meta?.title || 'Supabase Docs'} {meta?.subtitle && ( -

+
{meta.subtitle} -

+
)}
diff --git a/apps/docs/features/ui/guide/GuideHeader.tsx b/apps/docs/features/ui/guide/GuideHeader.tsx index 034adb0eed0..be7e79b1434 100644 --- a/apps/docs/features/ui/guide/GuideHeader.tsx +++ b/apps/docs/features/ui/guide/GuideHeader.tsx @@ -1,6 +1,7 @@ 'use client' import ReactMarkdown from 'react-markdown' + import { useGuide } from './Guide' interface GuideHeaderProps { @@ -16,9 +17,9 @@ export function GuideHeader({ className }: GuideHeaderProps) { {meta?.title || 'Supabase Docs'} {meta?.subtitle && ( -

+
{meta.subtitle} -

+ )}
diff --git a/apps/docs/fonts/index.ts b/apps/docs/fonts/index.ts new file mode 100644 index 00000000000..77c27d95a13 --- /dev/null +++ b/apps/docs/fonts/index.ts @@ -0,0 +1,15 @@ +import { Inter, Manrope } from 'next/font/google' + +export const manrope = Manrope({ + variable: '--font-manrope', + display: 'swap', + fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'], + subsets: ['latin'], +}) + +export const inter = Inter({ + variable: '--font-inter', + display: 'swap', + fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial', 'sans-serif'], + subsets: ['latin'], +}) diff --git a/apps/docs/lib/markdown-manifest.ts b/apps/docs/lib/markdown-manifest.ts index 05bf99b1093..2a268c9ca9a 100644 --- a/apps/docs/lib/markdown-manifest.ts +++ b/apps/docs/lib/markdown-manifest.ts @@ -516,6 +516,7 @@ export const MARKDOWN_SLUGS: readonly string[] = [ "storage/buckets/fundamentals", "storage/cdn/fundamentals", "storage/cdn/metrics", + "storage/cdn/purge-cdn-cache", "storage/cdn/smart-cdn", "storage/debugging/error-codes", "storage/debugging/logs", diff --git a/apps/docs/styles/globals.css b/apps/docs/styles/globals.css index 47b3026db95..e2207f0fa35 100644 --- a/apps/docs/styles/globals.css +++ b/apps/docs/styles/globals.css @@ -11,6 +11,29 @@ @source './../../../packages/ui/src/**/*.{tsx,ts,js}'; @source './../../../packages/ui-patterns/src/**/*.{tsx,ts,js}'; +@theme inline { + --font-sans: + var(--font-inter), Inter, Helvetica Neue, Helvetica, ui-sans-serif, system-ui, sans-serif; + --font-heading: var(--font-manrope, var(--font-sans)); + --font-mono: 'Source Code Pro', ui-monospace, Menlo, monospace; +} + +@theme { + --text-sm: 0.8125rem; + --text-base: 0.9375rem; + --text-lg: 1rem; + --text-xl: 1.125rem; + --text-2xl: 1.375rem; + --text-3xl: 1.75rem; + --text-4xl: 2.125rem; + --text-5xl: 2.875rem; + --text-6xl: 3.625rem; + --text-7xl: 4.375rem; + --text-8xl: 5.875rem; + --text-9xl: 7.875rem; + --font-weight-normal: 450; +} + @layer utilities { .prose--remove-p-margin p { margin: 0; @@ -25,26 +48,28 @@ ::file-selector-button { border-color: hsl(var(--border-default, currentColor)); } -} -@font-face { - font-family: 'custom-font'; - src: - url(../../../packages/common/assets/fonts/CustomFont-Book.woff2) format('woff2'), - url(../../../packages/common/assets/fonts/CustomFont-Book.woff) format('woff'); - font-weight: 400; - font-style: normal; - font-display: swap; -} - -@font-face { - font-family: 'custom-font'; - src: - url(../../../packages/common/assets/fonts/CustomFont-Medium.woff2) format('woff2'), - url(../../../packages/common/assets/fonts/CustomFont-Medium.woff) format('woff'); - font-weight: 500; - font-style: normal; - font-display: swap; + code, + .code-content, + pre, + kbd, + samp, + .font-mono { + --text-xs: 0.75rem; + --text-sm: 0.875rem; + --text-base: 1rem; + --text-lg: 1.125rem; + --text-xl: 1.25rem; + --text-2xl: 1.5rem; + --text-3xl: 1.875rem; + --text-4xl: 2.25rem; + --text-5xl: 3rem; + --text-6xl: 3.75rem; + --text-7xl: 4.5rem; + --text-8xl: 6rem; + --text-9xl: 8rem; + --font-weight-normal: 400; + } } :root { @@ -52,9 +77,17 @@ --header-height: 50px; } +html { + scroll-behavior: smooth; + font-weight: 500; +} + +html[data-theme*='dark'] { + font-weight: 450; +} + body { @apply bg-background text-foreground; - scroll-behavior: smooth; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -64,14 +97,19 @@ body { color: #333 !important; } -/* a { */ -/* text-decoration: none !important; */ -/* } */ - -article h1 { - /* margin-bottom: 2rem !important; */ - /* font-size: 3rem !important; */ - /* font-weight: 400 !important; */ +h1:not(.font-mono), +h2:not(.font-mono), +h3:not(.font-mono), +h4:not(.font-mono), +h5:not(.font-mono), +h6:not(.font-mono), +.h1:not(.font-mono), +.h2:not(.font-mono), +.h3:not(.font-mono), +.h4:not(.font-mono), +.h5:not(.font-mono), +.h6:not(.font-mono) { + @apply font-heading font-semibold; } .thin-scrollbar { @@ -103,10 +141,36 @@ article h1 { @apply text-foreground-muted text-xs; } -.prose :where(p):not(:where([class~='not-prose'] *)) { +.prose :where(p):not(:where([class~='not-prose'], [class~='not-prose'] *)) { white-space: pre-line; } +.prose :where(p, a, code, pre):not(:where([class~='not-prose'], [class~='not-prose'] *)), +.prose :where(ol > li):not(:where([class~='not-prose'], [class~='not-prose'] *))::marker { + font-weight: inherit; +} + +.prose :where(h1, h2, h3, h4, h5, h6):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply font-heading font-semibold; +} + +.prose + :where(p, li, a, figcaption):not(:where([class~='not-prose'], [class~='not-prose'] *)):not( + :where(h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6) * + ) { + @apply text-base leading-7; +} + +.prose :where(h1, .h1):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-4xl; +} +.prose :where(h2, .h2):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-2xl; +} +.prose :where(h3, .h3):not(:where([class~='not-prose'], [class~='not-prose'] *)) { + @apply text-xl leading-7; +} + code[class*='language-'], pre[class*='language-'] { text-shadow: none !important; @@ -136,7 +200,7 @@ pre[class*='language-'] { } .method-list-title-isChild { - font-family: var(--custom-font-size-xs); + font-family: var(--font-mono); & h5 { font-weight: 200; } diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx index 2307ecc7206..2456d57068d 100644 --- a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.tsx @@ -29,6 +29,7 @@ import { } from 'ui-patterns/PageSection' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' +import { parseRedirectMessage } from './AccountIdentities.utils' import { ChangeEmailAddressForm, GitHubChangeEmailAddress, @@ -68,7 +69,7 @@ export const AccountIdentities = () => { const [selectedProviderUpdateEmail, setSelectedProviderUpdateEmail] = useState() const [linkingProviderId, setLinkingProviderId] = useState() - const [, message] = router.asPath.split('#message=') + const message = parseRedirectMessage(router.asPath) const unlinkedExternalProviders = connectableExternalProviders.filter((provider) => { return !identities.some( (identity) => identity.provider === provider.authProvider || identity.provider === provider.id @@ -119,7 +120,7 @@ export const AccountIdentities = () => { } useEffect(() => { - if (message) toast.success(message.replaceAll('+', ' ')) + if (message) toast.success(message) }, [message]) return ( diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts new file mode 100644 index 00000000000..fffe0612f40 --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { parseRedirectMessage } from './AccountIdentities.utils' + +describe('parseRedirectMessage', () => { + it('drops the trailing sb marker and decodes + as spaces', () => { + expect( + parseRedirectMessage( + '/account/me#message=Confirmation+link+accepted.+Please+proceed+to+confirm+link+sent+to+the+other+email&sb=' + ) + ).toBe('Confirmation link accepted. Please proceed to confirm link sent to the other email') + }) + + it('returns undefined when there is no hash', () => { + expect(parseRedirectMessage('/account/me')).toBeUndefined() + }) + + it('returns undefined when the fragment has no message key', () => { + expect(parseRedirectMessage('/account/me#sb=')).toBeUndefined() + }) + + it('finds message even when it is not the first fragment param', () => { + expect(parseRedirectMessage('/account/me#sb=&message=Hi+there')).toBe('Hi there') + }) + + it('preserves a literal + via percent-encoding', () => { + expect(parseRedirectMessage('/account/me#message=a%2Bb&sb=')).toBe('a+b') + }) +}) diff --git a/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts new file mode 100644 index 00000000000..842c102db1c --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/AccountIdentities.utils.ts @@ -0,0 +1,2 @@ +export const parseRedirectMessage = (asPath: string) => + new URLSearchParams(asPath.split('#')[1] ?? '').get('message') ?? undefined diff --git a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx index 7a578f453d1..ebae4de5b4c 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx +++ b/apps/studio/components/interfaces/App/FeaturePreview/FeaturePreviewContext.tsx @@ -13,6 +13,7 @@ import { } from 'react' import { useFeaturePreviews } from './useFeaturePreviews' +import { IS_PLATFORM } from '@/lib/constants' import { EMPTY_OBJ } from '@/lib/void' type FeaturePreviewContextType = { @@ -43,6 +44,11 @@ export const FeaturePreviewContextProvider = ({ children }: PropsWithChildren) = const initializeFlags = useEffectEvent(() => { setFlags( featurePreviews.reduce((a, b) => { + // Platform-only previews can never be enabled outside the hosted platform + if (!IS_PLATFORM && b.isPlatformOnly) { + return { ...a, [b.key]: false } + } + const defaultOptIn = b.isDefaultOptIn const localStorageValue = safeLocalStorage.getItem(b.key) return { @@ -101,8 +107,7 @@ export const useUnifiedLogsPreview = () => { export const useIsPgDeltaDiffEnabled = () => { const { flags } = useFeaturePreviewContext() - const pgDeltaDiffEnabled = useFlag('pgdeltaDiff') - return pgDeltaDiffEnabled && flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PG_DELTA_DIFF] + return flags[LOCAL_STORAGE_KEYS.UI_PREVIEW_PG_DELTA_DIFF] } export const useIsAdvisorRulesEnabled = () => { diff --git a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts index 137ae7c8df2..79d6a6059dc 100644 --- a/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts +++ b/apps/studio/components/interfaces/App/FeaturePreview/useFeaturePreviews.ts @@ -20,7 +20,6 @@ export type FeaturePreview = { } export const useFeaturePreviews = (): FeaturePreview[] => { - const pgDeltaDiffEnabled = useFlag('pgdeltaDiff') const platformWebhooksEnabled = useFlag('platformWebhooks') const jitDbAccessEnabled = useFlag('jitDbAccess') const isMarketplaceEnabled = useFlag('marketplaceIntegrations') @@ -68,7 +67,7 @@ export const useFeaturePreviews = (): FeaturePreview[] => { isNew: false, isPlatformOnly: true, isDefaultOptIn: true, - enabled: pgDeltaDiffEnabled, + enabled: true, }, { key: LOCAL_STORAGE_KEYS.UI_PREVIEW_PLATFORM_WEBHOOKS, @@ -111,12 +110,6 @@ export const useFeaturePreviews = (): FeaturePreview[] => { getRoute: (ref?: string) => `/project/${ref}/integrations`, }, ].sort((a, b) => Number(b.isNew) - Number(a.isNew)), - [ - unifiedLogsDefaultOptIn, - pgDeltaDiffEnabled, - platformWebhooksEnabled, - jitDbAccessEnabled, - isMarketplaceEnabled, - ] + [unifiedLogsDefaultOptIn, platformWebhooksEnabled, jitDbAccessEnabled, isMarketplaceEnabled] ) } diff --git a/apps/studio/components/interfaces/Auth/Auth.constants.ts b/apps/studio/components/interfaces/Auth/Auth.constants.ts index 27458458bc6..d85bf61150f 100644 --- a/apps/studio/components/interfaces/Auth/Auth.constants.ts +++ b/apps/studio/components/interfaces/Auth/Auth.constants.ts @@ -42,5 +42,22 @@ export function urlRegex( ) } +export function normalizeRedirectUrl(url: string): string { + return url.trim().replace(/\s*,\s*$/, '') +} + +export function parseRedirectUrls(allowList?: string | null): string[] { + if (!allowList) return [] + + return Array.from( + new Set( + allowList + .split(',') + .map(normalizeRedirectUrl) + .filter((url) => url.length > 0) + ) + ) +} + // Use a const string to represent no chars option. Represented as empty string on the backend side. export const NO_REQUIRED_CHARACTERS = 'NO_REQUIRED_CHARS' diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.test.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.test.tsx index dc89c65f59e..43137ec556e 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.test.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.test.tsx @@ -80,7 +80,7 @@ describe('AddNewURLModal', () => { expect(toast.success).toHaveBeenCalledWith('Successfully added 1 URL') }) - it('dedupes URLs after normalising a trailing comma before submitting', async () => { + it('normalizes a trailing-comma URL before submitting', async () => { const user = userEvent.setup() mutateMock.mockImplementation((_vars, callbacks) => callbacks?.onSuccess?.()) @@ -88,11 +88,7 @@ describe('AddNewURLModal', () => { await screen.findByRole('dialog') - await user.type(screen.getByPlaceholderText('https://mydomain.com'), 'https://app.example.com') - await user.click(screen.getByRole('button', { name: 'Add URL' })) - - const urlInputs = screen.getAllByPlaceholderText('https://mydomain.com') - await user.type(urlInputs[1], 'https://app.example.com,') + await user.type(screen.getByPlaceholderText('https://mydomain.com'), 'https://app.example.com,') fireEvent.submit(screen.getByRole('dialog').querySelector('form') as HTMLFormElement) @@ -130,4 +126,24 @@ describe('AddNewURLModal', () => { expect(await screen.findByText('URL already exists in the allow list')).toBeInTheDocument() expect(mutateMock).not.toHaveBeenCalled() }) + + it('rejects a whitespace-padded URL when it already exists in the allow list', async () => { + const user = userEvent.setup() + + render( + + ) + + await screen.findByRole('dialog') + + await user.type( + screen.getByPlaceholderText('https://mydomain.com'), + ' https://existing.example.com ' + ) + + fireEvent.submit(screen.getByRole('dialog').querySelector('form') as HTMLFormElement) + + expect(await screen.findByText('URL already exists in the allow list')).toBeInTheDocument() + expect(mutateMock).not.toHaveBeenCalled() + }) }) diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx index fcd9160d02b..7e7cffa8b6c 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/AddNewURLModal.tsx @@ -22,7 +22,7 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' import { SingleValueFieldArray } from 'ui-patterns/form/SingleValueFieldArray/SingleValueFieldArray' import * as z from 'zod' -import { urlRegex } from '../Auth.constants' +import { normalizeRedirectUrl, parseRedirectUrls, urlRegex } from '../Auth.constants' import { useAuthConfigUpdateMutation } from '@/data/auth/auth-config-update-mutation' const MAX_URLS_LENGTH = 2 * 1024 @@ -33,61 +33,88 @@ interface AddNewURLModalProps { onClose: () => void } -const normaliseUrl = (value: string) => value.replace(/,\s*$/, '') +const createRedirectUrlsSchema = (normalizedAllowList: string[]) => { + const redirectUrlRegex = urlRegex() + + return z + .object({ + urls: z + .object({ + value: z.string().trim().min(1, 'Please provide a value').transform(normalizeRedirectUrl), + }) + .array() + .default([]), + }) + .superRefine((data, ctx) => { + const seenUrls = new Set() + + data.urls.forEach((url, index) => { + if (!redirectUrlRegex.test(url.value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['urls', index, 'value'], + message: 'Please provide a valid URL', + }) + } + + if (normalizedAllowList.includes(url.value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['urls', index, 'value'], + message: 'URL already exists in the allow list', + }) + } + + if (seenUrls.has(url.value)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['urls', index, 'value'], + message: 'URL already exists in this list', + }) + } + + seenUrls.add(url.value) + }) + }) +} export const AddNewURLModal = ({ visible, allowList, onClose }: AddNewURLModalProps) => { const { ref } = useParams() const { mutate: updateAuthConfig, isPending: isUpdatingConfig } = useAuthConfigUpdateMutation() - const redirectUrlRegex = urlRegex() - - const FormSchema = z.object({ - urls: z - .object({ - value: z - .string() - .min(1, 'Please provide a value') - .refine( - (value) => redirectUrlRegex.test(normaliseUrl(value)), - 'Please provide a valid URL' - ) - .refine((value) => !allowList.includes(normaliseUrl(value)), { - message: 'URL already exists in the allow list', - }), - }) - .array() - .default([]), - }) + const normalizedAllowList = parseRedirectUrls(allowList.join(',')) + const formSchema = createRedirectUrlsSchema(normalizedAllowList) const initialValues = { urls: [{ value: '' }] } - const form = useForm>({ - resolver: zodResolver(FormSchema), + const form = useForm>({ + resolver: zodResolver(formSchema), defaultValues: initialValues, }) const urls = form.watch('urls') - const onSubmit = (data: z.infer) => { - const dedupedUrls = [...new Set(data.urls.map((url) => normaliseUrl(url.value)))] - const payloadUrls = allowList.concat(dedupedUrls) - const addedCount = dedupedUrls.length - const payload = payloadUrls.toString() + const onSubmit = (data: z.infer) => { + const payload = parseRedirectUrls( + normalizedAllowList.concat(data.urls.map((url) => url.value)).join(',') + ) + const payloadString = payload.join(',') + const addedCount = payload.length - normalizedAllowList.length - if (payload.length > MAX_URLS_LENGTH) { + if (payloadString.length > MAX_URLS_LENGTH) { return toast.error('Too many redirect URLs, please remove some or try to use wildcards') - } else { - updateAuthConfig( - { projectRef: ref!, config: { URI_ALLOW_LIST: payload } }, - { - onError: (error) => { - toast.error(`Failed to add URL(s): ${error?.message}`) - }, - onSuccess: () => { - toast.success(`Successfully added ${addedCount} URL${addedCount > 1 ? 's' : ''}`) - form.reset(initialValues) - onClose() - }, - } - ) } + + updateAuthConfig( + { projectRef: ref!, config: { URI_ALLOW_LIST: payloadString } }, + { + onError: (error) => { + toast.error(`Failed to add URL(s): ${error?.message}`) + }, + onSuccess: () => { + toast.success(`Successfully added ${addedCount} URL${addedCount > 1 ? 's' : ''}`) + form.reset(initialValues) + onClose() + }, + } + ) } useEffect(() => { diff --git a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx index f23ce4574bd..0441dfab252 100644 --- a/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx +++ b/apps/studio/components/interfaces/Auth/RedirectUrls/RedirectUrls.tsx @@ -23,6 +23,7 @@ import { PageSectionTitle, } from 'ui-patterns/PageSection' +import { parseRedirectUrls } from '../Auth.constants' import { AddNewURLModal } from './AddNewURLModal' import { RedirectUrlList } from './RedirectUrlList' import { ValueContainer } from './ValueContainer' @@ -46,9 +47,7 @@ export const RedirectUrls = () => { useAuthConfigUpdateMutation() const URI_ALLOW_LIST_ARRAY = useMemo(() => { - return authConfig?.URI_ALLOW_LIST - ? authConfig.URI_ALLOW_LIST.split(/\s*[,]+\s*/).filter((url: string) => url) - : [] + return parseRedirectUrls(authConfig?.URI_ALLOW_LIST) }, [authConfig?.URI_ALLOW_LIST]) const [open, setOpen] = useState(false) @@ -58,8 +57,7 @@ export const RedirectUrls = () => { const onConfirmDeleteUrl = async (urls?: string[]) => { if (!urls || urls.length === 0) return - // Remove selectedUrl from array and update - const payload = URI_ALLOW_LIST_ARRAY.filter((url: string) => !selectedUrls.includes(url)) + const payload = URI_ALLOW_LIST_ARRAY.filter((url: string) => !urls.includes(url)) const payloadString = payload.join(',') await updateAuthConfig( { projectRef: projectRef!, config: { URI_ALLOW_LIST: payloadString } }, diff --git a/apps/studio/components/interfaces/Database/Policies/Policies.constants.ts b/apps/studio/components/interfaces/Database/Policies/Policies.constants.ts index 060d9ee81c3..2e1e7dcb2a7 100644 --- a/apps/studio/components/interfaces/Database/Policies/Policies.constants.ts +++ b/apps/studio/components/interfaces/Database/Policies/Policies.constants.ts @@ -1,6 +1,341 @@ -export const POLICY_MODAL_VIEWS = { - SELECTION: 'SELECTION', - TEMPLATES: 'TEMPLATES', - EDITOR: 'EDITOR', - REVIEW: 'REVIEW', +import { safeSql } from '@supabase/pg-meta/src/pg-format' +import type { SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' + +export interface PolicyTemplate { + id: string + preview: boolean + templateName: string + description: string + name: string + statement: string + definition: SafeSqlFragment + check: SafeSqlFragment + command: 'SELECT' | 'INSERT' | 'UPDATE' | 'DELETE' | 'ALL' + roles: Array +} + +/** + * ---------------------------------------------------------------- + * PostgreSQL policy templates for the auth policies page + * ---------------------------------------------------------------- + * id: Unique identifier for the monaco editor to dynamically refresh + * templateName: As a display for a more descriptive title for the policy + * description: Additional details about the template and how to make it yours + * statement: SQL statement template for the policy + * + * name: Actual policy name that will be used in the editor + * definition: Actual policy using expression that will be used in the editor + * check: Actual policy with check expression that will be used in the editor + * command: Operation to create policy for + */ + +export const getGeneralPolicyTemplates = (schema: string, table: string): PolicyTemplate[] => [ + { + id: 'policy-1', + preview: false, + templateName: 'Enable read access to everyone', + description: + 'This policy gives read access to your table for all users via the SELECT operation.', + statement: ` +create policy "Enable read access for all users" +on "${schema}"."${table}" +for select using (true);`.trim(), + name: 'Enable read access for all users', + definition: safeSql`true`, + check: safeSql``, + command: 'SELECT', + roles: [], + }, + { + id: 'policy-2', + preview: false, + templateName: 'Enable insert access for authenticated users only', + description: 'This policy gives insert access to your table for all authenticated users only.', + statement: ` +create policy "Enable insert for authenticated users only" +on "${schema}"."${table}" +for insert to authenticated +with check (true);`.trim(), + name: 'Enable insert for authenticated users only', + definition: safeSql``, + check: safeSql`true`, + command: 'INSERT', + roles: ['authenticated'], + }, + { + id: 'policy-3', + preview: false, + templateName: 'Enable delete access for users based on their user ID *', + description: + 'This policy assumes that your table has a column "user_id", and allows users to delete rows which the "user_id" column matches their ID', + statement: ` +create policy "Enable delete for users based on user_id" +on "${schema}"."${table}" +for delete using ( + (select auth.uid()) = user_id +);`.trim(), + name: 'Enable delete for users based on user_id', + definition: safeSql`(select auth.uid()) = user_id`, + check: safeSql``, + command: 'DELETE', + roles: [], + }, + { + id: 'policy-4', + preview: false, + templateName: 'Enable insert access for users based on their user ID *', + description: + 'This policy assumes that your table has a column "user_id", and allows users to insert rows which the "user_id" column matches their ID', + statement: ` +create policy "Enable insert for users based on user_id" +on "${schema}"."${table}" +for insert with check ( + (select auth.uid()) = user_id +);`.trim(), + name: 'Enable insert for users based on user_id', + definition: safeSql``, + check: safeSql`(select auth.uid()) = user_id`, + command: 'INSERT', + roles: [], + }, + { + id: 'policy-5', + preview: true, + name: 'Policy with table joins', + templateName: 'Policy with table joins', + description: ` +Query across tables to build more advanced RLS rules + +Assuming 2 tables called \`teams\` and \`members\`, you can query both tables in the policy to control access to the members table.`, + statement: ` +create policy "Members can update team details if they belong to the team" +on teams for update using ( + (select auth.uid()) in ( + select user_id from members where team_id = id + ) +); +`.trim(), + definition: safeSql`(select auth.uid()) in (select user_id from members where team_id = id)`, + check: safeSql``, + command: 'UPDATE', + roles: [], + }, + { + id: 'policy-6', + preview: true, + templateName: 'Policy with security definer functions', + description: ` +Useful in a many-to-many relationship where you want to restrict access to the linking table. + +Assuming 2 tables called \`teams\` and \`members\`, you can use a security definer function in combination with a policy to control access to the members table.`.trim(), + statement: ` +create or replace function get_teams_for_user(user_id uuid) +returns setof bigint as $$ + select team_id from members where user_id = $1 +$$ stable language sql security definer; + +create policy "Team members can update team members if they belong to the team" +on members +for all using ( + team_id in (select get_teams_for_user(auth.uid())) +); +`.trim(), + name: 'Policy with security definer functions', + definition: safeSql`team_id in (select get_teams_for_user(auth.uid()))`, + check: safeSql``, + command: 'ALL', + roles: [], + }, + { + id: 'policy-7', + preview: true, + name: 'Policy to implement Time To Live (TTL)', + templateName: 'Policy to implement Time To Live (TTL)', + description: ` +Implement a TTL-like feature that you see in Instagram stories or Snapchat where messages expire after a day. + +Rows under the table are available only if they have been created within the last 24 hours.`, + statement: ` +create policy "Stories are live for a day" +on "${schema}"."${table}" +for select using ( + created_at > (current_timestamp - interval '1 day') +); +`.trim(), + definition: safeSql`created_at > (current_timestamp - interval '1 day')`, + check: safeSql``, + command: 'SELECT', + roles: [], + }, + { + id: 'policy-8', + preview: false, + templateName: 'Allow users to only view their own data', + description: 'Restrict users to reading only their own data.', + statement: ` +create policy "Enable users to view their own data only" +on "${schema}"."${table}" +for select +to authenticated +using ( + (select auth.uid()) = user_id +);`.trim(), + name: 'Enable users to view their own data only', + definition: safeSql`(select auth.uid()) = user_id`, + check: safeSql``, + command: 'SELECT', + roles: ['authenticated'], + }, +] + +export const getRealtimePolicyTemplates = (): PolicyTemplate[] => { + const results = [ + { + id: 'policy-broadcast-1', + preview: false, + templateName: 'Allow listening for broadcasts for authenticated users only', + description: 'This policy allows listening for broadcasts for authenticated users only.', + statement: ` +create policy "Allow listening for broadcasts for authenticated users only" +on realtime.messages for select +to authenticated +using ( realtime.messages.extension = 'broadcast' );`.trim(), + name: 'Allow listening for broadcasts for authenticated users only', + definition: safeSql`realtime.messages.extension = 'broadcast'`, + check: safeSql``, + command: 'SELECT', + roles: ['authenticated'], + }, + { + id: 'policy-broadcast-2', + preview: false, + templateName: 'Allow pushing broadcasts for authenticated users only', + description: 'This policy allows pushing broadcasts for authenticated users only.', + statement: ` +create policy "Allow pushing broadcasts for authenticated users only" +ON realtime.messages for insert +TO authenticated +with check ( realtime.messages.extension = 'broadcast' );`.trim(), + name: 'Allow pushing broadcasts for authenticated users only', + definition: safeSql`realtime.messages.extension = 'broadcast'`, + check: safeSql`realtime.messages.extension = 'broadcast'`, + command: 'INSERT', + roles: ['authenticated'], + }, + { + id: 'policy-broadcast-3', + preview: false, + templateName: 'Allow listening for broadcasts from a specific channel', + description: 'This policy allows listening for broadcasts from a specific channel.', + statement: ` +create policy "Allow listening for broadcasts from a specific channel" +on realtime.messages for select +using ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(), + name: 'Allow listening for broadcasts from a specific channel', + definition: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`, + check: safeSql``, + command: 'SELECT', + roles: [], + }, + { + id: 'policy-broadcast-4', + preview: false, + templateName: 'Allow pushing broadcasts to specific channel', + description: 'This policy allow pushing broadcasts to specific channel.', + statement: ` +create policy "Allow pushing broadcasts to specific channel" +ON realtime.messages for insert +with check ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(), + name: 'Allow pushing broadcasts to specific channel', + definition: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`, + check: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`, + command: 'INSERT', + roles: [], + }, + { + id: 'policy-presences-1', + preview: false, + templateName: 'Allow listening for presences on all channels for authenticated users only', + description: + 'This policy enables listening for presences on all channels for all authenticated users only.', + statement: ` +create policy "Allow listening for presences on all channels for authenticated users only" +on realtime.messages for select +to authenticated +using ( realtime.messages.extension = 'presence' );`.trim(), + name: 'Allow listening for presences on all channels for authenticated users only', + definition: safeSql`realtime.messages.extension = 'presence'`, + check: safeSql``, + command: 'SELECT', + roles: ['authenticated'], + }, + { + id: 'policy-presences-2', + preview: false, + templateName: 'Allow broadcasting presences on all channels for authenticated users only', + description: + 'This policy enables broadcasting presences on all channels for all authenticated users only.', + statement: ` +create policy "Allow broadcasting presences on all channels for authenticated users only" +ON realtime.messages for insert +TO authenticated +with check ( realtime.messages.extension = 'presence' ); + ;`.trim(), + name: 'Allow broadcasting presences on all channels for authenticated users only', + definition: safeSql`realtime.messages.extension = 'presence'`, + check: safeSql`realtime.messages.extension = 'presence'`, + command: 'INSERT', + roles: ['authenticated'], + }, + { + id: 'policy-presences-3', + preview: false, + templateName: 'Allow listening for presences from a specific channel', + description: 'This policy enables listening for presences from a specific channel.', + statement: ` +create policy "Allow listening for presences from a specific channel" +on realtime.messages for select +using ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );`.trim(), + name: 'Allow listening for presences from a specific channel', + definition: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`, + check: safeSql``, + command: 'SELECT', + roles: [], + }, + { + id: 'policy-presences-4', + preview: false, + templateName: 'Publish presence to a specific channel', + description: 'This policy allows publishing presence to a specific channel.', + statement: ` +create policy "Publish presence to a specific channel" +ON realtime.messages for insert +with check ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' ); + ;`.trim(), + name: 'Publish presence to a specific channel', + definition: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`, + check: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`, + command: 'INSERT', + roles: [], + }, + ] as PolicyTemplate[] + return results +} + +export const getQueuePolicyTemplates = (): PolicyTemplate[] => { + return [ + { + id: 'policy-queues-1', + preview: false, + templateName: 'Allow access to queue', + statement: ``.trim(), + name: 'Allow anon and authenticated to access messages from queue', + description: + 'Base policy to ensure that anon and authenticated can only access appropriate rows. USING and CHECK statements will need to be adjusted accordingly', + definition: safeSql`true`, + check: safeSql`true`, + command: 'ALL', + roles: ['anon', 'authenticated'], + }, + ] } diff --git a/apps/studio/components/interfaces/Database/Policies/Policies.utils.test.ts b/apps/studio/components/interfaces/Database/Policies/Policies.utils.test.ts deleted file mode 100644 index e785b11c2b9..00000000000 --- a/apps/studio/components/interfaces/Database/Policies/Policies.utils.test.ts +++ /dev/null @@ -1,450 +0,0 @@ -import { safeSql } from '@supabase/pg-meta' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { - generateAiPoliciesForTable, - generateProgrammaticPoliciesForTable, - generateStartingPoliciesForTable, - type GeneratedPolicy, -} from './Policies.utils' -import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query' - -// Mock generateSqlPolicy for AI tests -const mockGenerateSqlPolicy = vi.fn() -vi.mock('@/data/ai/sql-policy-mutation', () => ({ - generateSqlPolicy: (...args: unknown[]) => mockGenerateSqlPolicy(...args), -})) - -// Helper to create a foreign key constraint -const createForeignKey = (overrides: Partial = {}): ForeignKeyConstraint => ({ - id: 1, - constraint_name: 'fk_constraint', - source_id: 100, - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_id: 200, - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - deletion_action: 'NO ACTION', - update_action: 'NO ACTION', - ...overrides, -}) - -describe('Policies.utils - Policy Generation', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - describe('generateProgrammaticPoliciesForTable', () => { - it('should generate 4 CRUD policies for direct FK to auth.users', () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - expect(policies).toHaveLength(4) - - const commands = policies.map((p) => p.command) - expect(commands).toContain('SELECT') - expect(commands).toContain('INSERT') - expect(commands).toContain('UPDATE') - expect(commands).toContain('DELETE') - }) - - it('should return empty array when no FK path to auth.users exists', () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['category_id'], - target_schema: 'public', - target_table: 'categories', - target_columns: ['id'], - }), - ] - - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - expect(policies).toHaveLength(0) - }) - - it('should return empty array when foreignKeyConstraints is empty', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints: [], - }) - - expect(policies).toHaveLength(0) - }) - - it('should generate policies with EXISTS clause for indirect FK path (2 hops)', () => { - // posts -> profiles -> auth.users - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - id: 1, - source_schema: 'public', - source_table: 'posts', - source_columns: ['profile_id'], - target_schema: 'public', - target_table: 'profiles', - target_columns: ['id'], - }), - createForeignKey({ - id: 2, - source_schema: 'public', - source_table: 'profiles', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - expect(policies).toHaveLength(4) - - // Check that the expression contains EXISTS for indirect path - const selectPolicy = policies.find((p) => p.command === 'SELECT') - expect(selectPolicy?.definition).toContain('exists') - expect(selectPolicy?.sql).toContain('exists') - }) - - describe('policy structure validation', () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - it('should include all required fields in generated policies', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - for (const policy of policies) { - expect(policy).toHaveProperty('name') - expect(policy).toHaveProperty('sql') - expect(policy).toHaveProperty('command') - expect(policy).toHaveProperty('table', 'posts') - expect(policy).toHaveProperty('schema', 'public') - expect(policy).toHaveProperty('action', 'PERMISSIVE') - expect(policy).toHaveProperty('roles') - expect(policy.roles).toContain('authenticated') - } - }) - - it('SELECT policy should have definition but no check', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const selectPolicy = policies.find((p) => p.command === 'SELECT') - expect(selectPolicy?.definition).toBeDefined() - expect(selectPolicy?.check).toBeUndefined() - }) - - it('DELETE policy should have definition but no check', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const deletePolicy = policies.find((p) => p.command === 'DELETE') - expect(deletePolicy?.definition).toBeDefined() - expect(deletePolicy?.check).toBeUndefined() - }) - - it('INSERT policy should have check but no definition', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const insertPolicy = policies.find((p) => p.command === 'INSERT') - expect(insertPolicy?.definition).toBeUndefined() - expect(insertPolicy?.check).toBeDefined() - }) - - it('UPDATE policy should have both definition and check', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const updatePolicy = policies.find((p) => p.command === 'UPDATE') - expect(updatePolicy?.definition).toBeDefined() - expect(updatePolicy?.check).toBeDefined() - }) - - it('should generate correct SQL syntax for direct FK', () => { - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - }) - - const selectPolicy = policies.find((p) => p.command === 'SELECT') - expect(selectPolicy?.sql).toContain('CREATE POLICY') - expect(selectPolicy?.sql).toContain('public.posts') - expect(selectPolicy?.sql).toContain('AS PERMISSIVE FOR SELECT') - expect(selectPolicy?.sql).toContain('TO authenticated') - expect(selectPolicy?.sql).toContain('USING') - expect(selectPolicy?.sql).toContain('auth.uid()') - }) - }) - - it('should handle non-public schema', () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'private', - source_table: 'documents', - source_columns: ['owner_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = generateProgrammaticPoliciesForTable({ - table: { name: 'documents', schema: 'private' }, - foreignKeyConstraints, - }) - - expect(policies).toHaveLength(4) - expect(policies[0].schema).toBe('private') - expect(policies[0].sql).toContain('private.documents') - }) - }) - - describe('generateAiPoliciesForTable', () => { - const mockAiPolicies: GeneratedPolicy[] = [ - { - name: 'ai_select_policy', - sql: 'CREATE POLICY "ai_select_policy" ON public.posts FOR SELECT USING (true);', - command: 'SELECT', - table: 'posts', - schema: 'public', - definition: safeSql`true`, - action: 'PERMISSIVE', - roles: ['public'], - }, - ] - - it('should return policies from AI when called with valid inputs', async () => { - mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies) - - const policies = await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: 'id' }, { name: 'title' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - }) - - expect(mockGenerateSqlPolicy).toHaveBeenCalledWith({ - tableName: 'posts', - schema: 'public', - columns: ['id', 'title'], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - }) - expect(policies).toEqual(mockAiPolicies) - }) - - it('should return empty array when connectionString is null', async () => { - const policies = await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: null, - }) - - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - expect(policies).toEqual([]) - }) - - it('should return empty array when connectionString is undefined', async () => { - const policies = await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: undefined, - }) - - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - expect(policies).toEqual([]) - }) - - it('should handle API errors gracefully and return empty array', async () => { - const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - mockGenerateSqlPolicy.mockRejectedValue(new Error('API error')) - - const policies = await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - }) - - expect(policies).toEqual([]) - expect(consoleLogSpy).toHaveBeenCalledWith('AI policy generation failed:', expect.any(Error)) - - consoleLogSpy.mockRestore() - }) - - it('should trim column names before sending to API', async () => { - mockGenerateSqlPolicy.mockResolvedValue([]) - - await generateAiPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - columns: [{ name: ' id ' }, { name: ' title ' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - }) - - expect(mockGenerateSqlPolicy).toHaveBeenCalledWith( - expect.objectContaining({ - columns: ['id', 'title'], - }) - ) - }) - }) - - describe('generateStartingPoliciesForTable', () => { - const mockAiPolicies: GeneratedPolicy[] = [ - { - name: 'ai_policy', - sql: 'CREATE POLICY "ai_policy" ON public.posts FOR SELECT USING (true);', - command: 'SELECT', - table: 'posts', - schema: 'public', - definition: safeSql`true`, - action: 'PERMISSIVE', - roles: ['public'], - }, - ] - - it('should use programmatic policies when FK path exists (does not call AI)', async () => { - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: true, - }) - - expect(policies).toHaveLength(4) - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - }) - - it('should fall back to AI when no FK path exists and enableAi is true', async () => { - mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies) - - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints: [], - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: true, - }) - - expect(mockGenerateSqlPolicy).toHaveBeenCalled() - expect(policies).toEqual(mockAiPolicies) - }) - - it('should return empty array when no FK path exists and enableAi is false', async () => { - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints: [], - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: false, - }) - - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - expect(policies).toEqual([]) - }) - - it('should return empty array when no FK path and AI returns empty', async () => { - mockGenerateSqlPolicy.mockResolvedValue([]) - - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints: [], - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: true, - }) - - expect(policies).toEqual([]) - }) - - it('should prioritize programmatic over AI even when both could generate policies', async () => { - mockGenerateSqlPolicy.mockResolvedValue(mockAiPolicies) - - const foreignKeyConstraints: ForeignKeyConstraint[] = [ - createForeignKey({ - source_schema: 'public', - source_table: 'posts', - source_columns: ['user_id'], - target_schema: 'auth', - target_table: 'users', - target_columns: ['id'], - }), - ] - - const policies = await generateStartingPoliciesForTable({ - table: { name: 'posts', schema: 'public' }, - foreignKeyConstraints, - columns: [{ name: 'id' }], - projectRef: 'test-project', - connectionString: 'postgresql://localhost:5432/test', - enableAi: true, - }) - - // Should return 4 programmatic policies, not 1 AI policy - expect(policies).toHaveLength(4) - expect(mockGenerateSqlPolicy).not.toHaveBeenCalled() - }) - }) -}) diff --git a/apps/studio/components/interfaces/Database/Policies/Policies.utils.ts b/apps/studio/components/interfaces/Database/Policies/Policies.utils.ts deleted file mode 100644 index d98c9322558..00000000000 --- a/apps/studio/components/interfaces/Database/Policies/Policies.utils.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { - acceptUntrustedSql, - ident, - safeSql, - untrustedSql, - type DisplayableSqlFragment, - type SafeSqlFragment, -} from '@supabase/pg-meta' -import type { PGPolicy } from '@supabase/pg-meta' -import { has, isEmpty, isEqual } from 'lodash' - -import { - DraftPostgresPolicyCreatePayload, - DraftPostgresPolicyUpdatePayload, - PolicyFormField, - PolicyForReview, -} from './Policies.types' -import { generateSqlPolicy } from '@/data/ai/sql-policy-mutation' -import type { CreatePolicyBody } from '@/data/database-policies/database-policy-create-mutation' -import type { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query' - -/** - * Returns an array of SQL statements that will preview in the review step of the policy editor - * @param {*} policyFormFields { name, using, check, command } - */ - -export const createSQLPolicy = ( - policyFormFields: PolicyFormField, - originalPolicyFormFields?: PGPolicy -) => { - const { definition, check } = policyFormFields - const formattedPolicyFormFields = { - ...policyFormFields, - definition: definition - ? definition.replace(/\s+/g, ' ').trim() - : definition === undefined - ? null - : definition, - check: check ? check.replace(/\s+/g, ' ').trim() : check === undefined ? null : check, - } - - if (!originalPolicyFormFields || isEmpty(originalPolicyFormFields)) { - return createSQLStatementForCreatePolicy(formattedPolicyFormFields) - } - - // If there are no changes, return an empty object - if (isEqual(policyFormFields, originalPolicyFormFields)) { - return {} - } - - // Extract out all the fields that updated - const fieldsToUpdate: any = {} - if (!isEqual(formattedPolicyFormFields.name, originalPolicyFormFields.name)) { - fieldsToUpdate.name = formattedPolicyFormFields.name - } - if (!isEqual(formattedPolicyFormFields.definition, originalPolicyFormFields.definition)) { - fieldsToUpdate.definition = formattedPolicyFormFields.definition - } - if (!isEqual(formattedPolicyFormFields.check, originalPolicyFormFields.check)) { - fieldsToUpdate.check = formattedPolicyFormFields.check - } - if (!isEqual(formattedPolicyFormFields.roles, originalPolicyFormFields.roles)) { - fieldsToUpdate.roles = formattedPolicyFormFields.roles - } - - if (!isEmpty(fieldsToUpdate)) { - return createSQLStatementForUpdatePolicy(formattedPolicyFormFields, fieldsToUpdate) - } - - return {} -} - -const createSQLStatementForCreatePolicy = (policyFormFields: PolicyFormField): PolicyForReview => { - const { name, definition, check, command, schema, table } = policyFormFields - const roles = policyFormFields.roles.length === 0 ? ['public'] : policyFormFields.roles - const description = `Add policy for the ${command} operation under the policy "${name}"` - const statement = [ - `CREATE POLICY "${name}" ON "${schema}"."${table}"`, - `AS PERMISSIVE FOR ${command}`, - `TO ${roles.join(', ')}`, - `${definition ? `USING (${definition})` : ''}`, - `${check ? `WITH CHECK (${check})` : ''}`, - ].join('\n') - - return { description, statement } -} - -const createSQLStatementForUpdatePolicy = ( - policyFormFields: PolicyFormField, - fieldsToUpdate: Partial -): PolicyForReview => { - const { name, schema, table } = policyFormFields - - const definitionChanged = has(fieldsToUpdate, ['definition']) - const checkChanged = has(fieldsToUpdate, ['check']) - const nameChanged = has(fieldsToUpdate, ['name']) - const rolesChanged = has(fieldsToUpdate, ['roles']) - - const parameters = Object.keys(fieldsToUpdate) - const description = `Update policy's ${ - parameters.length === 1 - ? parameters[0] - : `${parameters.slice(0, parameters.length - 1).join(', ')} and ${ - parameters[parameters.length - 1] - }` - } ` - const roles = - (fieldsToUpdate?.roles ?? []).length === 0 ? ['public'] : (fieldsToUpdate.roles as string[]) - - const alterStatement = `ALTER POLICY "${name}" ON "${schema}"."${table}"` - const statement = [ - 'BEGIN;', - ...(definitionChanged ? [` ${alterStatement} USING (${fieldsToUpdate.definition});`] : []), - ...(checkChanged ? [` ${alterStatement} WITH CHECK (${fieldsToUpdate.check});`] : []), - ...(rolesChanged ? [` ${alterStatement} TO ${roles.join(', ')};`] : []), - ...(nameChanged ? [` ${alterStatement} RENAME TO "${fieldsToUpdate.name}";`] : []), - 'COMMIT;', - ].join('\n') - - return { description, statement } -} - -// These constructors return DRAFT payloads — `definition`/`check` are still -// `DisplayableSqlFragment`. Promotion to `SafeSqlFragment` must happen at the user gesture -// (the Save click in `PolicyEditorModal`), not here, since this module has no guarantee that -// it was reached via a deliberate user action. -export const createPayloadForCreatePolicy = ( - policyFormFields: PolicyFormField -): DraftPostgresPolicyCreatePayload => { - const { name, schema, table, command, definition, check, roles } = policyFormFields - return { - name, - schema, - table, - action: 'PERMISSIVE', - command: command || undefined, - definition: !definition ? undefined : untrustedSql(definition), - check: !check ? undefined : untrustedSql(check), - roles: roles.length > 0 ? roles : undefined, - } -} - -export const createPayloadForUpdatePolicy = ( - policyFormFields: PolicyFormField, - originalPolicyFormFields: PGPolicy -): DraftPostgresPolicyUpdatePayload => { - const { definition, check } = policyFormFields - const formattedDefinition = definition ? definition.replace(/\s+/g, ' ').trim() : definition - const formattedCheck = check ? check.replace(/\s+/g, ' ').trim() : check - - const payload: DraftPostgresPolicyUpdatePayload = { id: originalPolicyFormFields.id } - - if (!isEqual(policyFormFields.name, originalPolicyFormFields.name)) { - payload.name = policyFormFields.name - } - if (!isEqual(formattedDefinition, originalPolicyFormFields.definition)) { - payload.definition = !formattedDefinition ? undefined : untrustedSql(formattedDefinition) - } - if (!isEqual(formattedCheck, originalPolicyFormFields.check)) { - payload.check = !formattedCheck ? undefined : untrustedSql(formattedCheck) - } - if (!isEqual(policyFormFields.roles, originalPolicyFormFields.roles)) { - if (policyFormFields.roles.length === 0) payload.roles = ['public'] - else payload.roles = policyFormFields.roles || undefined - } - - return payload -} - -// --- Policy Generation --- - -/** - * A policy generated for display/staging in the table editor. - * `definition`/`check` are `DisplayableSqlFragment` because generators have different provenance: - * programmatic generation produces `SafeSqlFragment` (composed via `safeSql`), AI generation - * produces `UntrustedSqlFragment` (third-party output). Consumers must promote via - * `acceptUntrustedSql` at a user gesture before executing. - */ -export type GeneratedPolicy = Required< - Pick -> & - Pick & { - definition?: DisplayableSqlFragment - check?: DisplayableSqlFragment - sql: string - } - -/** - * A {@link GeneratedPolicy} whose `definition`/`check` have already been promoted to - * `SafeSqlFragment`. Producing one of these is the contract that says: the user gesture - * required to execute this SQL has already happened. - */ -export type AcceptedGeneratedPolicy = Omit & { - definition?: SafeSqlFragment - check?: SafeSqlFragment -} - -/** - * Promotes a {@link GeneratedPolicy} to an {@link AcceptedGeneratedPolicy}. - * ONLY call from an event handler tied to a deliberate user action (e.g. the Save click - * on the table editor). Never call from useEffect, render, or any path that runs without - * a user gesture. - */ -export const acceptGeneratedPolicy = (policy: GeneratedPolicy): AcceptedGeneratedPolicy => ({ - ...policy, - definition: policy.definition === undefined ? undefined : acceptUntrustedSql(policy.definition), - check: policy.check === undefined ? undefined : acceptUntrustedSql(policy.check), -}) - -type Relationship = { - source_schema: string - source_table_name: string - source_column_name: string - target_table_schema: string - target_table_name: string - target_column_name: string -} - -/** - * Gets relationships for a specific table from FK constraints. - * Returns relationships where the table is the source. - */ -const getRelationshipsForTable = ({ - schema, - table, - fkConstraints, -}: { - schema: string - table: string - fkConstraints: ForeignKeyConstraint[] -}): Relationship[] => { - return fkConstraints - .filter((fk) => fk.source_schema === schema && fk.source_table === table) - .flatMap((fk) => - fk.source_columns.map((sourceCol, i) => ({ - source_schema: fk.source_schema, - source_table_name: fk.source_table, - source_column_name: sourceCol, - target_table_schema: fk.target_schema, - target_table_name: fk.target_table, - target_column_name: fk.target_columns[i], - })) - ) -} - -/** - * BFS to find shortest path from table to auth.users via foreign key relationships. - * Returns null if no path exists within maxDepth. - */ -const findPathToAuthUsers = ( - startTable: { schema: string; name: string }, - allForeignKeyConstraints: ForeignKeyConstraint[], - maxDepth = 3 -): Relationship[] | null => { - const startRelationships = getRelationshipsForTable({ - schema: startTable.schema, - table: startTable.name, - fkConstraints: allForeignKeyConstraints, - }) - - const queue: { table: { schema: string; name: string }; path: Relationship[] }[] = [ - { table: startTable, path: [] }, - ] - const visited = new Set() - visited.add(`${startTable.schema}.${startTable.name}`) - - while (queue.length > 0) { - const queueItem = queue.shift() - if (!queueItem) continue - - const { table, path } = queueItem - if (path.length >= maxDepth) continue - - const relationships = - path.length === 0 - ? startRelationships - : getRelationshipsForTable({ - schema: table.schema, - table: table.name, - fkConstraints: allForeignKeyConstraints, - }) - - for (const rel of relationships) { - // Found path to auth.users - if ( - rel.target_table_schema === 'auth' && - rel.target_table_name === 'users' && - rel.target_column_name === 'id' - ) { - return [...path, rel] - } - - const targetId = `${rel.target_table_schema}.${rel.target_table_name}` - if (visited.has(targetId)) continue - - // Add target table to queue for further exploration - queue.push({ - table: { schema: rel.target_table_schema, name: rel.target_table_name }, - path: [...path, rel], - }) - visited.add(targetId) - } - } - - return null -} - -/** Generates SQL expression for RLS policy based on FK path to auth.users */ -const buildPolicyExpression = (path: Relationship[]): SafeSqlFragment => { - if (path.length === 0) return safeSql`` - - // Direct FK to auth.users - if (path.length === 1) { - return safeSql`(select auth.uid()) = ${ident(path[0].source_column_name)}` - } - - // Indirect path - build EXISTS with JOINs - const [first, ...rest] = path - const firstTarget = safeSql`${ident(first.target_table_schema)}.${ident(first.target_table_name)}` - const source = safeSql`${ident(first.source_schema)}.${ident(first.source_table_name)}` - const last = path[path.length - 1] - - const joins = rest.slice(0, -1).reduce( - (acc, r) => { - const targetSchema = ident(r.target_table_schema) - const targetTable = ident(r.target_table_name) - const targetColumn = ident(r.target_column_name) - - const sourceSchema = ident(r.source_schema) - const sourceTable = ident(r.source_table_name) - const sourceColumn = ident(r.source_column_name) - const join = safeSql`join ${targetSchema}.${targetTable} on ${targetSchema}.${targetTable}.${targetColumn} = ${sourceSchema}.${sourceTable}.${sourceColumn}` - return acc.length === 0 ? join : safeSql`${acc}\n ${join}` - }, - safeSql`` - ) - - return safeSql`exists ( - select 1 from ${firstTarget} - ${joins} - where ${firstTarget}.${ident(first.target_column_name)} = ${source}.${ident(first.source_column_name)} - and ${ident(last.source_schema)}.${ident(last.source_table_name)}.${ident(last.source_column_name)} = (select auth.uid()) -)` -} - -/** Builds policy SQL for all CRUD operations */ -const buildPoliciesForPath = ( - table: { name: string; schema: string }, - path: Relationship[] -): GeneratedPolicy[] => { - const expression = buildPolicyExpression(path) - const targetCol = path[0].source_column_name - - return (['SELECT', 'INSERT', 'UPDATE', 'DELETE'] as const).map((command) => { - const name = `Enable ${command.toLowerCase()} access for users based on ${ident(targetCol)}` - const base = `CREATE POLICY "${name}" ON ${ident(table.schema)}.${ident(table.name)} AS PERMISSIVE FOR ${command} TO authenticated` - - const sql = - command === 'INSERT' - ? `${base} WITH CHECK (${expression});` - : command === 'UPDATE' - ? `${base} USING (${expression}) WITH CHECK (${expression});` - : `${base} USING (${expression});` - - // Structured data for mutation API - const definition = command === 'INSERT' ? undefined : expression - const check = command === 'SELECT' || command === 'DELETE' ? undefined : expression - - return { - name, - sql, - command, - table: table.name, - schema: table.schema, - definition, - check, - action: 'PERMISSIVE' as const, - roles: ['authenticated'], - } - }) -} - -/** - * Generates RLS policies programmatically based on FK relationships to auth.users. - */ -export const generateProgrammaticPoliciesForTable = ({ - table, - foreignKeyConstraints, -}: { - table: { name: string; schema: string } - foreignKeyConstraints: ForeignKeyConstraint[] -}): GeneratedPolicy[] => { - try { - const path = findPathToAuthUsers(table, foreignKeyConstraints) - - if (path?.length) { - return buildPoliciesForPath(table, path) - } - } catch (error) { - // Silently fail - caller will handle empty result - } - - return [] -} - -/** - * Generates RLS policies using AI. - */ -export const generateAiPoliciesForTable = async ({ - table, - columns, - projectRef, - connectionString, -}: { - table: { name: string; schema: string } - columns: { name: string }[] - projectRef: string - connectionString?: string | null -}): Promise => { - if (!connectionString) return [] - - try { - return await generateSqlPolicy({ - tableName: table.name, - schema: table.schema, - columns: columns.map((col) => col.name.trim()), - projectRef, - connectionString: connectionString ?? '', - }) - } catch (error) { - console.log('AI policy generation failed:', error) - return [] - } -} - -/** - * Generates RLS policies for a table. - * First tries programmatic generation based on FK relationships to auth.users. - * Falls back to AI generation if no path exists. - */ -export const generateStartingPoliciesForTable = async ({ - table, - foreignKeyConstraints, - columns, - projectRef, - connectionString, - enableAi, -}: { - table: { name: string; schema: string } - foreignKeyConstraints: ForeignKeyConstraint[] - columns: { name: string }[] - projectRef: string - connectionString?: string | null - enableAi: boolean -}): Promise => { - // Try programmatic generation first - const programmaticPolicies = generateProgrammaticPoliciesForTable({ - table, - foreignKeyConstraints, - }) - - if (programmaticPolicies.length > 0) { - return programmaticPolicies - } - - // Fall back to AI generation - if (enableAi) { - return await generateAiPoliciesForTable({ - table, - columns, - projectRef, - connectionString, - }) - } - - return [] -} diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts b/apps/studio/components/interfaces/Database/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts deleted file mode 100644 index 9e918f6cc11..00000000000 --- a/apps/studio/components/interfaces/Database/Policies/PolicyEditorModal/PolicyEditorModal.constants.ts +++ /dev/null @@ -1,329 +0,0 @@ -import { safeSql } from '@supabase/pg-meta/src/pg-format' - -import { PolicyTemplate } from '../PolicyTemplates/PolicyTemplates.constants' - -/** - * ---------------------------------------------------------------- - * PostgreSQL policy templates for the auth policies page - * ---------------------------------------------------------------- - * id: Unique identifier for the monaco editor to dynamically refresh - * templateName: As a display for a more descriptive title for the policy - * description: Additional details about the template and how to make it yours - * statement: SQL statement template for the policy - * - * name: Actual policy name that will be used in the editor - * definition: Actual policy using expression that will be used in the editor - * check: Actual policy with check expression that will be used in the editor - * command: Operation to create policy for - */ - -export const getGeneralPolicyTemplates = (schema: string, table: string): PolicyTemplate[] => [ - { - id: 'policy-1', - preview: false, - templateName: 'Enable read access to everyone', - description: - 'This policy gives read access to your table for all users via the SELECT operation.', - statement: ` -create policy "Enable read access for all users" -on "${schema}"."${table}" -for select using (true);`.trim(), - name: 'Enable read access for all users', - definition: safeSql`true`, - check: safeSql``, - command: 'SELECT', - roles: [], - }, - { - id: 'policy-2', - preview: false, - templateName: 'Enable insert access for authenticated users only', - description: 'This policy gives insert access to your table for all authenticated users only.', - statement: ` -create policy "Enable insert for authenticated users only" -on "${schema}"."${table}" -for insert to authenticated -with check (true);`.trim(), - name: 'Enable insert for authenticated users only', - definition: safeSql``, - check: safeSql`true`, - command: 'INSERT', - roles: ['authenticated'], - }, - { - id: 'policy-3', - preview: false, - templateName: 'Enable delete access for users based on their user ID *', - description: - 'This policy assumes that your table has a column "user_id", and allows users to delete rows which the "user_id" column matches their ID', - statement: ` -create policy "Enable delete for users based on user_id" -on "${schema}"."${table}" -for delete using ( - (select auth.uid()) = user_id -);`.trim(), - name: 'Enable delete for users based on user_id', - definition: safeSql`(select auth.uid()) = user_id`, - check: safeSql``, - command: 'DELETE', - roles: [], - }, - { - id: 'policy-4', - preview: false, - templateName: 'Enable insert access for users based on their user ID *', - description: - 'This policy assumes that your table has a column "user_id", and allows users to insert rows which the "user_id" column matches their ID', - statement: ` -create policy "Enable insert for users based on user_id" -on "${schema}"."${table}" -for insert with check ( - (select auth.uid()) = user_id -);`.trim(), - name: 'Enable insert for users based on user_id', - definition: safeSql``, - check: safeSql`(select auth.uid()) = user_id`, - command: 'INSERT', - roles: [], - }, - { - id: 'policy-5', - preview: true, - name: 'Policy with table joins', - templateName: 'Policy with table joins', - description: ` -Query across tables to build more advanced RLS rules - -Assuming 2 tables called \`teams\` and \`members\`, you can query both tables in the policy to control access to the members table.`, - statement: ` -create policy "Members can update team details if they belong to the team" -on teams for update using ( - (select auth.uid()) in ( - select user_id from members where team_id = id - ) -); -`.trim(), - definition: safeSql`(select auth.uid()) in (select user_id from members where team_id = id)`, - check: safeSql``, - command: 'UPDATE', - roles: [], - }, - { - id: 'policy-6', - preview: true, - templateName: 'Policy with security definer functions', - description: ` -Useful in a many-to-many relationship where you want to restrict access to the linking table. - -Assuming 2 tables called \`teams\` and \`members\`, you can use a security definer function in combination with a policy to control access to the members table.`.trim(), - statement: ` -create or replace function get_teams_for_user(user_id uuid) -returns setof bigint as $$ - select team_id from members where user_id = $1 -$$ stable language sql security definer; - -create policy "Team members can update team members if they belong to the team" -on members -for all using ( - team_id in (select get_teams_for_user(auth.uid())) -); -`.trim(), - name: 'Policy with security definer functions', - definition: safeSql`team_id in (select get_teams_for_user(auth.uid()))`, - check: safeSql``, - command: 'ALL', - roles: [], - }, - { - id: 'policy-7', - preview: true, - name: 'Policy to implement Time To Live (TTL)', - templateName: 'Policy to implement Time To Live (TTL)', - description: ` -Implement a TTL-like feature that you see in Instagram stories or Snapchat where messages expire after a day. - -Rows under the table are available only if they have been created within the last 24 hours.`, - statement: ` -create policy "Stories are live for a day" -on "${schema}"."${table}" -for select using ( - created_at > (current_timestamp - interval '1 day') -); -`.trim(), - definition: safeSql`created_at > (current_timestamp - interval '1 day')`, - check: safeSql``, - command: 'SELECT', - roles: [], - }, - { - id: 'policy-8', - preview: false, - templateName: 'Allow users to only view their own data', - description: 'Restrict users to reading only their own data.', - statement: ` -create policy "Enable users to view their own data only" -on "${schema}"."${table}" -for select -to authenticated -using ( - (select auth.uid()) = user_id -);`.trim(), - name: 'Enable users to view their own data only', - definition: safeSql`(select auth.uid()) = user_id`, - check: safeSql``, - command: 'SELECT', - roles: ['authenticated'], - }, -] - -export const getRealtimePolicyTemplates = (): PolicyTemplate[] => { - const results = [ - { - id: 'policy-broadcast-1', - preview: false, - templateName: 'Allow listening for broadcasts for authenticated users only', - description: 'This policy allows listening for broadcasts for authenticated users only.', - statement: ` -create policy "Allow listening for broadcasts for authenticated users only" -on realtime.messages for select -to authenticated -using ( realtime.messages.extension = 'broadcast' );`.trim(), - name: 'Allow listening for broadcasts for authenticated users only', - definition: safeSql`realtime.messages.extension = 'broadcast'`, - check: safeSql``, - command: 'SELECT', - roles: ['authenticated'], - }, - { - id: 'policy-broadcast-2', - preview: false, - templateName: 'Allow pushing broadcasts for authenticated users only', - description: 'This policy allows pushing broadcasts for authenticated users only.', - statement: ` -create policy "Allow pushing broadcasts for authenticated users only" -ON realtime.messages for insert -TO authenticated -with check ( realtime.messages.extension = 'broadcast' );`.trim(), - name: 'Allow pushing broadcasts for authenticated users only', - definition: safeSql`realtime.messages.extension = 'broadcast'`, - check: safeSql`realtime.messages.extension = 'broadcast'`, - command: 'INSERT', - roles: ['authenticated'], - }, - { - id: 'policy-broadcast-3', - preview: false, - templateName: 'Allow listening for broadcasts from a specific channel', - description: 'This policy allows listening for broadcasts from a specific channel.', - statement: ` -create policy "Allow listening for broadcasts from a specific channel" -on realtime.messages for select -using ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(), - name: 'Allow listening for broadcasts from a specific channel', - definition: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`, - check: safeSql``, - command: 'SELECT', - roles: [], - }, - { - id: 'policy-broadcast-4', - preview: false, - templateName: 'Allow pushing broadcasts to specific channel', - description: 'This policy allow pushing broadcasts to specific channel.', - statement: ` -create policy "Allow pushing broadcasts to specific channel" -ON realtime.messages for insert -with check ( realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name' );`.trim(), - name: 'Allow pushing broadcasts to specific channel', - definition: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`, - check: safeSql`realtime.messages.extension = 'broadcast' AND realtime.topic() = 'channel_name'`, - command: 'INSERT', - roles: [], - }, - { - id: 'policy-presences-1', - preview: false, - templateName: 'Allow listening for presences on all channels for authenticated users only', - description: - 'This policy enables listening for presences on all channels for all authenticated users only.', - statement: ` -create policy "Allow listening for presences on all channels for authenticated users only" -on realtime.messages for select -to authenticated -using ( realtime.messages.extension = 'presence' );`.trim(), - name: 'Allow listening for presences on all channels for authenticated users only', - definition: safeSql`realtime.messages.extension = 'presence'`, - check: safeSql``, - command: 'SELECT', - roles: ['authenticated'], - }, - { - id: 'policy-presences-2', - preview: false, - templateName: 'Allow broadcasting presences on all channels for authenticated users only', - description: - 'This policy enables broadcasting presences on all channels for all authenticated users only.', - statement: ` -create policy "Allow broadcasting presences on all channels for authenticated users only" -ON realtime.messages for insert -TO authenticated -with check ( realtime.messages.extension = 'presence' ); - ;`.trim(), - name: 'Allow broadcasting presences on all channels for authenticated users only', - definition: safeSql`realtime.messages.extension = 'presence'`, - check: safeSql`realtime.messages.extension = 'presence'`, - command: 'INSERT', - roles: ['authenticated'], - }, - { - id: 'policy-presences-3', - preview: false, - templateName: 'Allow listening for presences from a specific channel', - description: 'This policy enables listening for presences from a specific channel.', - statement: ` -create policy "Allow listening for presences from a specific channel" -on realtime.messages for select -using ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' );`.trim(), - name: 'Allow listening for presences from a specific channel', - definition: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`, - check: safeSql``, - command: 'SELECT', - roles: [], - }, - { - id: 'policy-presences-4', - preview: false, - templateName: 'Publish presence to a specific channel', - description: 'This policy allows publishing presence to a specific channel.', - statement: ` -create policy "Publish presence to a specific channel" -ON realtime.messages for insert -with check ( realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name' ); - ;`.trim(), - name: 'Publish presence to a specific channel', - definition: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`, - check: safeSql`realtime.messages.extension = 'presence' AND realtime.topic() = 'channel_name'`, - command: 'INSERT', - roles: [], - }, - ] as PolicyTemplate[] - return results -} - -export const getQueuePolicyTemplates = (): PolicyTemplate[] => { - return [ - { - id: 'policy-queues-1', - preview: false, - templateName: 'Allow access to queue', - statement: ``.trim(), - name: 'Allow anon and authenticated to access messages from queue', - description: - 'Base policy to ensure that anon and authenticated can only access appropriate rows. USING and CHECK statements will need to be adjusted accordingly', - definition: safeSql`true`, - check: safeSql`true`, - command: 'ALL', - roles: ['anon', 'authenticated'], - }, - ] -} diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx index ea7da926e6f..77d54208dad 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyEditorPanel/PolicyTemplates.tsx @@ -17,7 +17,7 @@ import { getGeneralPolicyTemplates, getQueuePolicyTemplates, getRealtimePolicyTemplates, -} from '../PolicyEditorModal/PolicyEditorModal.constants' +} from '../Policies.constants' import { Markdown } from '@/components/interfaces/Markdown' import CardButton from '@/components/ui/CardButton' import CopyButton from '@/components/ui/CopyButton' diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRowHeader.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRowHeader.tsx index fc4df51adea..36ef1d8dc4d 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRowHeader.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/PolicyTableRowHeader.tsx @@ -56,24 +56,26 @@ export const PolicyTableRowHeader = ({ > {table.name} + +
{!table.rls_enabled && ( - + RLS Disabled )} {!isLoadingApiAccess && !hasApiAccess && ( - + API Disabled )} - - {isTableLocked && ( - - - Locked - - - )} + {isTableLocked && ( + + + Locked + + + )} +
{!isTableLocked && (
diff --git a/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx index 8098b16c72e..2e60637e59b 100644 --- a/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx +++ b/apps/studio/components/interfaces/Database/Policies/PolicyTableRow/index.tsx @@ -94,7 +94,9 @@ const PolicyTableRowComponent = ({ return ( - + -} diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts index 954a1ac0a5d..edadd80c1b9 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.hooks.ts @@ -2,6 +2,7 @@ import { useMemo } from 'react' import { UseFormReturn } from 'react-hook-form' import { DiskStorageSchemaType } from '../DiskManagement.schema' +import { ComputeInstanceAddonVariantId } from '../DiskManagement.types' import { calculateComputeSizePrice, calculateDiskSizePrice, @@ -18,6 +19,13 @@ import { useSelectedProjectQuery, } from '@/hooks/misc/useSelectedProject' +const COMPUTE_SIZES_BELOW_LARGE: Array = [ + 'ci_nano', + 'ci_micro', + 'ci_small', + 'ci_medium', +] + export function useDiskManagementReviewChanges( form: UseFormReturn, numReplicas: number @@ -131,6 +139,16 @@ export function useDiskManagementReviewChanges( // Show cooldown warning whenever any disk attribute that enforces the 4-hour lock changes const anyDiskAttributeChange = hasIOPSChanges || hasStorageTypeChanges || hasTotalSizeChanges + // Show extended downtime warning when resizing to/from a size below large + const hasExtendedDowntimeRisk = + hasComputeChanges && + (COMPUTE_SIZES_BELOW_LARGE.includes( + (form.formState.defaultValues?.computeSize ?? 'ci_nano') as ComputeInstanceAddonVariantId + ) || + COMPUTE_SIZES_BELOW_LARGE.includes( + form.getValues('computeSize') as ComputeInstanceAddonVariantId + )) + // Throughput is only a user-configurable, separately-billed attribute for GP3. For IO2 it is // derived from provisioned IOPS (0.256 MiB/s per IOPS) and isn't surfaced as its own value, so // the form clears it to 0 — rendering a misleading "→ 0 MB/s". Only show the row when the @@ -180,6 +198,7 @@ export function useDiskManagementReviewChanges( anyDiskAttributeChange, showThroughputRow, hasAnyBreakdownRows, + hasExtendedDowntimeRisk, // labels oldComputeLabel, newComputeLabel, diff --git a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.tsx b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.tsx index 67839cc3310..8b9d38811eb 100644 --- a/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.tsx +++ b/apps/studio/components/interfaces/DiskManagement/DiskManagementReviewAndSubmitDialog/DiskManagementReviewAndSubmitDialog.tsx @@ -1,5 +1,5 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { ArrowRight } from 'lucide-react' +import { AlertTriangle, ArrowRight } from 'lucide-react' import { UseFormReturn } from 'react-hook-form' import { Alert, @@ -84,6 +84,7 @@ export const DiskManagementReviewAndSubmitDialog = ({ anyDiskAttributeChange, showThroughputRow, hasAnyBreakdownRows, + hasExtendedDowntimeRisk, oldComputeLabel, newComputeLabel, } = useDiskManagementReviewChanges(form, numReplicas) @@ -267,6 +268,20 @@ export const DiskManagementReviewAndSubmitDialog = ({
)} + {hasExtendedDowntimeRisk && ( + <> + + + + + + Resizes may require more downtime than normal on this project. + + + + + )} + - - - - - ) -} diff --git a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx index b918ebd41f0..245ef423660 100644 --- a/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx +++ b/apps/studio/components/interfaces/Storage/StorageSettings/StorageSettings.tsx @@ -1,6 +1,6 @@ import { zodResolver } from '@hookform/resolvers/zod' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { IS_PLATFORM, useFlag, useParams } from 'common' +import { IS_PLATFORM, useParams } from 'common' import { useEffect, useMemo, useState } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -29,10 +29,6 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' import * as z from 'zod' import { StorageFileSizeLimitErrorMessage } from './StorageFileSizeLimitErrorMessage' -import { - StorageListV2MigratingCallout, - StorageListV2MigrationCallout, -} from './StorageListV2MigrationCallout' import { STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_CAPPED, STORAGE_FILE_SIZE_LIMIT_MAX_BYTES_UNCAPPED, @@ -70,8 +66,6 @@ export const StorageSettings = () => { const { ref: projectRef } = useParams() const { data: project } = useSelectedProjectQuery() - const showMigrationCallout = useFlag('storageMigrationCallout') - const { can: canReadStorageSettings, isLoading: isLoadingPermissions } = useAsyncCheckPermissions( PermissionAction.STORAGE_ADMIN_READ, '*' @@ -88,10 +82,6 @@ export const StorageSettings = () => { isSuccess, isError, } = useProjectStorageConfigQuery({ projectRef }) - const isListV2UpgradeAvailable = - !!config && !config.capabilities.list_v2 && config.external.upstreamTarget === 'main' - const isListV2Upgrading = - !!config && !config.capabilities.list_v2 && config.external.upstreamTarget === 'canary' const { runCondition: sizeLimitCheckCondition, @@ -282,209 +272,200 @@ export const StorageSettings = () => { /> )} {isSuccess && ( - <> - {showMigrationCallout && ( - <> - {isListV2UpgradeAvailable && } - {isListV2Upgrading && } - - )} -
- - - ( - - Optimize and resize images on the fly.{' '} - - Learn more - - . - - } - > - - - - - )} - /> - - - - ( - - Restrict the size of files uploaded across all buckets.{' '} - - Learn more - - . - {!shouldAutoValidateBucketLimits && ( -

- Ensure that the global limit is greater than that of - individual buckets -

- )} - - } - > - -
- { - field.onChange(e) - form.clearErrors('fileSizeLimit') - }} - className="w-32 rounded-r-none border-r-0" - disabled={ - !hasAccessToFileSizeConfiguration || - !canUpdateStorageSettings - } - /> - ( - - )} - /> -
-
- {sizeLimitCheckCondition === 'confirm' && ( - - )} -
- )} - /> - {fileSizeLimitError && ( - - - - )} -
- {hasLimitedStorageAccess && ( - - )} - {isSpendCapOn && ( - - )} - - {!canUpdateStorageSettings && ( - -

- You need additional permissions to update storage settings -

-
- )} - - - {form.formState.isDirty && ( - + + + + )} + /> + + + + ( + + Restrict the size of files uploaded across all buckets.{' '} + + Learn more + + . + {!shouldAutoValidateBucketLimits && ( +

+ Ensure that the global limit is greater than that of + individual buckets +

+ )} + + } + > + +
+ { + field.onChange(e) + form.clearErrors('fileSizeLimit') + }} + className="w-32 rounded-r-none border-r-0" + disabled={ + !hasAccessToFileSizeConfiguration || !canUpdateStorageSettings + } + /> + ( + + )} + /> +
+
+ {sizeLimitCheckCondition === 'confirm' && ( + + )} +
+ )} + /> + {fileSizeLimitError && ( + + + + )} +
+ {hasLimitedStorageAccess && ( + + )} + {isSpendCapOn && ( + + )} + + {!canUpdateStorageSettings && ( + +

+ You need additional permissions to update storage settings +

+
+ )} + + + {form.formState.isDirty && ( - -
- - + )} + + + + )} )} diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index ec146b9d980..05a0d70b445 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -37,10 +37,6 @@ import type { ImportContent } from './TableEditor/TableEditor.types' import { useTableRowOperations } from '@/components/grid/hooks/useTableRowOperations' import { getStableRowIdentifiers } from '@/components/grid/utils/queueOperationUtils' import { useIsQueueOperationsEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings' -import { - acceptGeneratedPolicy, - type GeneratedPolicy, -} from '@/components/interfaces/Database/Policies/Policies.utils' import { DiscardChangesConfirmationDialog } from '@/components/ui-patterns/Dialogs/DiscardChangesConfirmationDialog' import { databasePoliciesKeys } from '@/data/database-policies/keys' import { useDatabasePublicationCreateMutation } from '@/data/database-publications/database-publications-create-mutation' @@ -83,7 +79,6 @@ type SaveTableParamsBase = { columns: ColumnField[] foreignKeyRelations: ForeignKey[] resolve: () => void - generatedPolicies?: GeneratedPolicy[] } type SaveTableParamsNew = SaveTableParamsBase & { @@ -598,7 +593,6 @@ export const SidePanelEditor = ({ configuration, columns, foreignKeyRelations, - generatedPolicies = [], resolve, }: SaveTableParams) => { let toastId @@ -663,7 +657,6 @@ export const SidePanelEditor = ({ 'table.has_rls': isRLSEnabled ? 1 : 0, 'table.has_foreign_keys': foreignKeyRelations.length > 0 ? 1 : 0, 'table.has_import': importContent !== undefined ? 1 : 0, - 'table.generated_policies_count': generatedPolicies.length, 'project.region': project?.region ?? 'local', ...(project?.cloud_provider && { 'project.cloud_provider': project.cloud_provider, @@ -674,13 +667,7 @@ export const SidePanelEditor = ({ }) try { - // The Save click is the explicit user gesture that promotes generated policy - // SQL (programmatic or AI) to executable. Programmatic fragments are already - // SafeSqlFragment; AI fragments are UntrustedSqlFragment — both are accepted - // here before being passed into createTable. - const acceptedPolicies = generatedPolicies.map(acceptGeneratedPolicy) - - const { table, failedPolicies } = await createTable({ + const { table } = await createTable({ projectRef: project?.ref!, connectionString: project?.connectionString, toastId, @@ -689,13 +676,10 @@ export const SidePanelEditor = ({ foreignKeyRelations, isRLSEnabled, importContent, - generatedPolicies: acceptedPolicies, - onCreatePoliciesSuccess: () => track('rls_generated_policies_created'), track, }) createTableSpan.setAttribute('table.created', 1) - createTableSpan.setAttribute('table.failed_policies', failedPolicies.length) await Sentry.startSpan( { name: 'create_table.post_creation', op: 'db.table.post_creation' }, @@ -736,24 +720,7 @@ export const SidePanelEditor = ({ } ) - // Show success toast after everything is complete - if (failedPolicies.length > 0) { - toast.success( - `Table ${table.name} is created successfully, but we ran into issues creating ${failedPolicies.length} policie${failedPolicies.length > 1 ? 's' : ''}`, - { - id: toastId, - description: ( -
    - {failedPolicies.map((x) => ( -
  • {x.name}
  • - ))} -
- ), - } - ) - } else { - toast.success(`Table ${table.name} is good to go!`, { id: toastId }) - } + toast.success(`Table ${table.name} is good to go!`, { id: toastId }) onTableCreated(table) } catch (error) { diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts index 02e843e0f2c..2f5b366797a 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.createTable.test.ts @@ -132,7 +132,6 @@ describe('createTable', () => { }) expect(mockTrack).toHaveBeenCalledWith('table_created', { - has_generated_policies: false, method: 'table_editor', schema_name: 'public', table_name: 'test_table', @@ -148,7 +147,6 @@ describe('createTable', () => { ) expect(result).toStrictEqual({ - failedPolicies: [], table: mockTableResult, }) }) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx index 5221b41011d..63b72069d9d 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.utils.tsx @@ -1,4 +1,5 @@ import * as Sentry from '@sentry/nextjs' +import type { PGTablePrimaryKey } from '@supabase/pg-meta' import pgMeta, { getAddForeignKeySQL, getAddPrimaryKeySQL, @@ -11,7 +12,6 @@ import pgMeta, { getUpdateIdentitySequenceSQL, type ForeignKey, } from '@supabase/pg-meta' -import type { PGTablePrimaryKey } from '@supabase/pg-meta' import { joinSqlFragments, safeSql, type SafeSqlFragment } from '@supabase/pg-meta/src/pg-format' import { Query } from '@supabase/pg-meta/src/query' import { chunk, find, isEmpty, isEqual } from 'lodash' @@ -26,12 +26,10 @@ import type { ColumnField, CreateColumnPayload, UpdateColumnPayload } from './Si import { checkIfRelationChanged } from './TableEditor/ForeignKeysManagement/ForeignKeysManagement.utils' import type { ImportContent } from './TableEditor/TableEditor.types' import type { SupaRow } from '@/components/grid/types' -import { type AcceptedGeneratedPolicy } from '@/components/interfaces/Database/Policies/Policies.utils' import { SparkBar } from '@/components/ui/SparkBar' import { createDatabaseColumn } from '@/data/database-columns/database-column-create-mutation' import { deleteDatabaseColumn } from '@/data/database-columns/database-column-delete-mutation' import { updateDatabaseColumn } from '@/data/database-columns/database-column-update-mutation' -import { createDatabasePolicy } from '@/data/database-policies/database-policy-create-mutation' import type { Constraint } from '@/data/database/constraints-query' import { ForeignKeyConstraint } from '@/data/database/foreign-key-constraints-query' import { databaseKeys } from '@/data/database/keys' @@ -443,8 +441,6 @@ export const createTable = async ({ foreignKeyRelations, isRLSEnabled, importContent, - generatedPolicies = [], - onCreatePoliciesSuccess, track, }: { projectRef: string @@ -459,8 +455,6 @@ export const createTable = async ({ foreignKeyRelations: ForeignKey[] isRLSEnabled: boolean importContent?: ImportContent - generatedPolicies?: AcceptedGeneratedPolicy[] - onCreatePoliciesSuccess?: () => void track: Track }) => { const queryClient = getQueryClient() @@ -545,51 +539,10 @@ export const createTable = async ({ } ) - // 6. Create generated RLS policies if any - // [Joshen] Possible area for optimization to create all policies in a single query call - // Can be subsequently added to the table creation SQL as well for a single transaction - - const failedPolicies: AcceptedGeneratedPolicy[] = [] - if (generatedPolicies.length > 0 && isRLSEnabled) { - await Sentry.startSpan( - { name: 'create_table.create_policies', op: 'db.policies.create' }, - async (span) => { - span.setAttribute('policies.count', generatedPolicies.length) - toast.loading(`Creating ${generatedPolicies.length} policies for table...`, { id: toastId }) - await Promise.all( - generatedPolicies.map(async (policy) => { - try { - return await createDatabasePolicy({ - projectRef, - connectionString, - payload: { - name: policy.name, - table: policy.table, - schema: policy.schema, - definition: policy.definition, - check: policy.check, - action: policy.action, - command: policy.command, - roles: policy.roles, - }, - }) - } catch (error: any) { - console.error('Failed to generate policy', error.message) - failedPolicies.push(policy) - } - }) - ) - span.setAttribute('policies.failed_count', failedPolicies.length) - onCreatePoliciesSuccess?.() - } - ) - } - track('table_created', { method: 'table_editor', schema_name: payload.schema, table_name: payload.name, - has_generated_policies: generatedPolicies.length > 0 && isRLSEnabled, }) if (isRLSEnabled) { @@ -726,7 +679,7 @@ export const createTable = async ({ ) // Finally, return the created table - return { table, failedPolicies } + return { table } } /** TODO: Refactor to do in a single transaction */ diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index 86d0a076fd0..a8313d0cd1a 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -242,7 +242,6 @@ export const TableEditor = ({ columns, foreignKeyRelations: fkRelations, resolve, - generatedPolicies: [], }) } else if (isDuplicating) { const payload: SaveTablePayloadFor<'duplicate'> = { @@ -256,7 +255,6 @@ export const TableEditor = ({ columns, foreignKeyRelations: fkRelations, resolve, - generatedPolicies: [], }) } else { const payload: SaveTablePayloadFor<'update'> = { @@ -271,7 +269,6 @@ export const TableEditor = ({ columns, foreignKeyRelations: fkRelations, resolve, - generatedPolicies: [], }) } } else { diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx index be43282627f..36dd514026a 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.constants.tsx @@ -17,6 +17,7 @@ import { export const REGIONS = ['ams', 'fra', 'gru', 'hkg', 'iad', 'syd'] as const export const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] as const export const LOG_TYPES_LABELS = { + edge: 'API Gateway', postgres: 'Postgres', postgrest: 'PostgREST', auth: 'Auth', @@ -29,7 +30,7 @@ export const LOG_TYPES_LABELS = { type LogType = keyof typeof LOG_TYPES_LABELS export const LOG_TYPES = Object.keys(LOG_TYPES_LABELS) as [LogType, ...LogType[]] -export const DEFAULT_LOG_TYPES = ['postgres', 'postgrest'] as const +export const DEFAULT_LOG_TYPES = ['postgres', 'edge'] as const const parseAsSort = createParser({ parse(queryValue: string) { @@ -72,6 +73,9 @@ export const SEARCH_PARAMS_PARSER = { // View options show_connection_logs: parseAsBoolean.withDefault(true), + edge_auth: parseAsBoolean.withDefault(true), + edge_storage: parseAsBoolean.withDefault(true), + edge_postgrest: parseAsBoolean.withDefault(true), } const POSTGRES_STATUS_CODE_LABELS = { diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx index fcae710eebf..4ae39344de1 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.fields.tsx @@ -28,7 +28,15 @@ export const filterFields = [ options: // [Joshen] Nested options are treated as just boolean toggles atm for simplicity // Refer to DataTableFilterCheckbox for their logic - value === 'postgres' ? [{ label: 'Connection logs', value: 'show_connection_logs' }] : [], + value === 'edge' + ? [ + { label: 'Auth', value: 'edge_auth' }, + { label: 'Storage', value: 'edge_storage' }, + { label: 'Postgrest', value: 'edge_postgrest' }, + ] + : value === 'postgres' + ? [{ label: 'Connection logs', value: 'show_connection_logs' }] + : [], })), component: (props: Option) => { return ( diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts index d0e75d0fd9c..69b419ec01e 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.test.ts @@ -17,31 +17,28 @@ const withFilters = (...entries: string[]) => ({ ...baseSearch, filter: entries describe('UnifiedLogs.queries (OTEL flat)', () => { describe('getUnifiedLogsQuery', () => { - it('defaults to postgres + postgrest log types when none specified', () => { + it('defaults to postgres + edge log types when none specified', () => { const sql = getUnifiedLogsQuery(baseSearch) const where = sql.split(/\bWHERE\b/)[1] ?? '' expect(where).toContain(`source = 'postgres_logs'`) - // postgrest = postgrest_logs OR edge_logs with /rest/ path - expect(where).toContain(`source = 'postgrest_logs'`) - expect(where).toContain(`log_attributes['request.path'] LIKE '%/rest/%'`) + expect(where).toContain(`source = 'edge_logs'`) + expect(where).not.toContain(`source = 'postgrest_logs'`) }) - it('routes the `postgrest` log type to postgrest_logs or edge_logs /rest/', () => { + it('routes the `postgrest` log type solely to postgrest_logs (mutually exclusive from edge_logs)', () => { const sql = getUnifiedLogsQuery(withFilters('log_type:eq:postgrest')) const where = sql.split(/\bWHERE\b/)[1] ?? '' expect(where).toContain(`source = 'postgrest_logs'`) - expect(where).toContain( - `source = 'edge_logs' AND log_attributes['request.path'] LIKE '%/rest/%'` - ) + expect(where).not.toContain(`source = 'edge_logs'`) + expect(where).not.toContain(`log_attributes['request.path'] LIKE '%/rest/%'`) }) - it('routes the `storage` log type to storage_logs or edge_logs /storage/', () => { + it('routes the `storage` log type solely to storage_logs (mutually exclusive from edge_logs)', () => { const sql = getUnifiedLogsQuery(withFilters('log_type:eq:storage')) const where = sql.split(/\bWHERE\b/)[1] ?? '' expect(where).toContain(`source = 'storage_logs'`) - expect(where).toContain( - `source = 'edge_logs' AND log_attributes['request.path'] LIKE '%/storage/%'` - ) + expect(where).not.toContain(`source = 'edge_logs'`) + expect(where).not.toContain(`log_attributes['request.path'] LIKE '%/storage/%'`) }) it('escapes single quotes in filter values to prevent SQL injection', () => { @@ -127,6 +124,45 @@ describe('UnifiedLogs.queries (OTEL flat)', () => { expect(sql).not.toContain("event_message NOT LIKE 'connection received%'") }) + it.each([ + ['edge_auth', '%/auth/%'], + ['edge_storage', '%/storage/%'], + ['edge_postgrest', '%/rest/%'], + ] as const)('excludes %s-pathed requests from edge_logs when %s=false', (key, pathFilter) => { + const sql = getUnifiedLogsQuery({ ...baseSearch, [key]: false } as any) + expect(sql).toContain("source != 'edge_logs'") + expect(sql).toContain(`log_attributes['request.path'] NOT LIKE '${pathFilter}'`) + }) + + it('does not filter edge_logs by service path by default (all edge_* toggles true)', () => { + const sql = getUnifiedLogsQuery(baseSearch) + expect(sql).not.toContain("log_attributes['request.path'] NOT LIKE '%/auth/%'") + expect(sql).not.toContain("log_attributes['request.path'] NOT LIKE '%/storage/%'") + expect(sql).not.toContain("log_attributes['request.path'] NOT LIKE '%/rest/%'") + }) + + it('leaves dedicated auth_logs/storage_logs/postgrest_logs rows untouched by the edge_* toggles', () => { + // These toggles only hide traffic nested inside the `edge_logs` (API + // Gateway) source — the dedicated sources are separate log types now + // that log types are mutually exclusive, so they shouldn't be scoped by + // a `source != 'edge_logs' OR ...` guard meant for gateway rows. + const sql = getUnifiedLogsQuery({ + ...baseSearch, + edge_auth: false, + edge_storage: false, + edge_postgrest: false, + } as any) + expect(sql).toContain( + "(source != 'edge_logs' OR log_attributes['request.path'] NOT LIKE '%/auth/%')" + ) + expect(sql).toContain( + "(source != 'edge_logs' OR log_attributes['request.path'] NOT LIKE '%/storage/%')" + ) + expect(sql).toContain( + "(source != 'edge_logs' OR log_attributes['request.path'] NOT LIKE '%/rest/%')" + ) + }) + it('does not emit subqueries or CTEs (rejected by the OTEL endpoint)', () => { const sql = getUnifiedLogsQuery(baseSearch) expect(sql).not.toMatch(/WITH\s+\w+\s+AS\s*\(/i) @@ -163,7 +199,8 @@ describe('UnifiedLogs.queries (OTEL flat)', () => { const sql = getLogsCountQuery(withFilters('log_type:eq:storage')) // Assert on the WHERE only: value expressions mention other sources inline. const totalWhere = whereOfBranchContaining(sql, `'all'`) - expect(totalWhere).toContain(`source = 'edge_logs'`) + expect(totalWhere).toContain(`source = 'storage_logs'`) + expect(totalWhere).not.toContain(`source = 'edge_logs'`) expect(totalWhere).not.toContain(`source = 'postgres_logs'`) }) @@ -181,6 +218,15 @@ describe('UnifiedLogs.queries (OTEL flat)', () => { expect(scan).toContain("event_message NOT LIKE 'connection received%'") } }) + + it('applies the edge_* service filters to every count scan so badges match the list', () => { + const sql = getLogsCountQuery({ ...baseSearch, edge_postgrest: false } as any) + const scans = sql.split(/\bUNION ALL\b/) + expect(scans.length).toBeGreaterThan(1) + for (const scan of scans) { + expect(scan).toContain("log_attributes['request.path'] NOT LIKE '%/rest/%'") + } + }) }) describe('getLogsChartQuery', () => { diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts index d186951ffca..2968e77238b 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.queries.ts @@ -55,9 +55,9 @@ const HTTP_STATUS_EXPR: SafeLogSqlFragment = safeSql`if(source = 'auth_logs', lo * logs; the UI surfaces gateway HTTP traffic for those buckets. */ const LOG_TYPE_CONDITION: Record = { - edge: safeSql`source = 'edge_logs' AND ${ATTR.path} NOT LIKE '%/rest/%' AND ${ATTR.path} NOT LIKE '%/storage/%'`, - postgrest: safeSql`source = 'postgrest_logs' OR (source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%')`, - storage: safeSql`source = 'storage_logs' OR (source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%')`, + edge: safeSql`source = 'edge_logs'`, + postgrest: safeSql`source = 'postgrest_logs'`, + storage: safeSql`source = 'storage_logs'`, postgres: safeSql`source = 'postgres_logs'`, 'edge function': safeSql`source = 'function_edge_logs'`, auth: safeSql`source = 'auth_logs'`, @@ -67,11 +67,11 @@ const LOG_TYPE_CONDITION: Record = { } // Derived `log_type` column for SELECT / GROUP BY / countIf use. +// WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%' THEN 'postgrest' +// WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%' THEN 'storage' const LOG_TYPE_EXPR: SafeLogSqlFragment = safeSql`CASE WHEN source = 'postgrest_logs' THEN 'postgrest' - WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/rest/%' THEN 'postgrest' WHEN source = 'storage_logs' THEN 'storage' - WHEN source = 'edge_logs' AND ${ATTR.path} LIKE '%/storage/%' THEN 'storage' WHEN source = 'edge_logs' THEN 'edge' WHEN source = 'postgres_logs' THEN 'postgres' WHEN source = 'function_edge_logs' THEN 'edge function' @@ -294,26 +294,54 @@ const buildBaseWhere = ( } } - const connFilter = connectionLogsFilter(search) - if (connFilter) parts.push(connFilter) + const searchParamsFilter = applySearchParamsFilter(search) + if (searchParamsFilter) parts.push(searchParamsFilter) return parts } +// Path substrings that identify which downstream service an `edge_logs` +// (API Gateway) row was routed to. Mirrors the convention already used by +// the sibling Logs Explorer (Logs.constants.ts / Logs.utils.otel.ts) and by +// ServiceFlow.sql.ts within this same feature. +const EDGE_SERVICE_PATH_FILTER: Record<'edge_auth' | 'edge_storage' | 'edge_postgrest', string> = { + edge_auth: '%/auth/%', + edge_storage: '%/storage/%', + edge_postgrest: '%/rest/%', +} + /** - * Returns a WHERE condition that excludes Postgres connection lifecycle messages. - * Shared by every query via `buildBaseWhere`, so the row list, chart and sidebar - * facet counts all hide connection logs together (otherwise the badges over-count - * by the connection rows the list hides). + * Returns view-option WHERE conditions — toggles from the filter sidebar that + * hide a subset of rows without being a `filter` URL param (Postgres + * connection lifecycle messages, and per-service traffic nested inside the + * API Gateway `edge_logs` source). Shared by every query via `buildBaseWhere`, + * so the row list, chart and sidebar facet counts stay in sync (otherwise the + * badges over-count by the rows the list hides). */ -const connectionLogsFilter = (search: QuerySearchParamsType): SafeLogSqlFragment | null => { +const applySearchParamsFilter = (search: QuerySearchParamsType): SafeLogSqlFragment | null => { + const conditions: SafeLogSqlFragment[] = [] + // Visible by default — only an explicit `false` hides connection logs. - if (search.show_connection_logs !== false) return null - return safeSql`(source != 'postgres_logs' OR ( - event_message NOT LIKE 'connection received%' AND - event_message NOT LIKE 'connection authenticated%' AND - event_message NOT LIKE 'connection authorized%' - ))` + if (search.show_connection_logs === false) { + conditions.push(safeSql`(source != 'postgres_logs' OR ( + event_message NOT LIKE 'connection received%' AND + event_message NOT LIKE 'connection authenticated%' AND + event_message NOT LIKE 'connection authorized%' + ))`) + } + + // Visible by default — only an explicit `false` hides that service's + // requests within the API Gateway log type. + for (const key of ['edge_auth', 'edge_storage', 'edge_postgrest'] as const) { + if (search[key] === false) { + conditions.push( + safeSql`(source != 'edge_logs' OR ${ATTR.path} NOT LIKE ${lit(EDGE_SERVICE_PATH_FILTER[key])})` + ) + } + } + + if (conditions.length === 0) return null + return safeSql`(${joinSqlFragments(conditions, ' AND ')})` } /** diff --git a/apps/studio/components/interfaces/UserDropdown.tsx b/apps/studio/components/interfaces/UserDropdown.tsx index 905c88adf64..afb84b9e966 100644 --- a/apps/studio/components/interfaces/UserDropdown.tsx +++ b/apps/studio/components/interfaces/UserDropdown.tsx @@ -1,4 +1,3 @@ -import { useFlag } from 'common' import { FlaskConical, Loader2, ScrollText, User2 } from 'lucide-react' import { useTheme } from 'next-themes' import Link from 'next/link' @@ -43,7 +42,6 @@ export function UserDropdown({ const { theme, setTheme } = useTheme() const appStateSnapshot = useAppStateSnapshot() const profileShowEmailEnabled = useIsFeatureEnabled('profile:show_email') - const timezonePickerEnabled = useFlag('timezonePicker') const { username, avatarUrl, primaryEmail, isLoading } = useProfileNameAndPicture() const { toggleFeaturePreviewModal } = useFeaturePreviewModal() @@ -106,7 +104,9 @@ export function UserDropdown({ )} + + )} + Theme - {timezonePickerEnabled && ( - <> - - - - - - )} + + + + + + + {showUpgradeCta && ( <> diff --git a/apps/studio/components/interfaces/UserDropdown/TimezoneDropdown.tsx b/apps/studio/components/interfaces/UserDropdown/TimezoneDropdown.tsx index 8844d7d7a06..f48990d77ec 100644 --- a/apps/studio/components/interfaces/UserDropdown/TimezoneDropdown.tsx +++ b/apps/studio/components/interfaces/UserDropdown/TimezoneDropdown.tsx @@ -23,8 +23,9 @@ import { useTrack } from '@/lib/telemetry/track' const AUTO_OPTION_VALUE = '__auto__' export const TimezoneDropdown = () => { - const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone() const track = useTrack() + const { timezone, storedTimezone, setTimezone, isAutoDetected } = useTimezone() + const [open, setOpen] = useState(false) // The "Auto detect" row always advertises the browser's own timezone, even diff --git a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx index 4e5bed9e943..3a24136e309 100644 --- a/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx +++ b/apps/studio/components/layouts/DatabaseLayout/DatabaseMenu.utils.tsx @@ -1,53 +1,35 @@ import { useParams } from 'common' -import { ArrowUpRight } from 'lucide-react' -import { - useIsColumnLevelPrivilegesEnabled, - useIsMarketplaceEnabled, -} from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' +import { useIsColumnLevelPrivilegesEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext' import { useIsETLPrivateAlpha } from '@/components/interfaces/Database/Replication/useIsETLPrivateAlpha' import type { ProductMenuGroup, ProductMenuGroupItem, } from '@/components/ui/ProductMenu/ProductMenu.types' -import { useDatabaseExtensionsQuery } from '@/data/database-extensions/database-extensions-query' import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' import { IS_PLATFORM } from '@/lib/constants' import { SHORTCUT_IDS } from '@/state/shortcuts/registry' -const ExternalLinkIcon = - export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => { const { ref } = useParams() const { data: project } = useSelectedProjectQuery() - const { - databaseReplication: showPgReplicate, - databaseRoles: showRoles, - integrationsWrappers: showWrappers, - } = useIsFeatureEnabled(['database:replication', 'database:roles', 'integrations:wrappers']) + const { databaseReplication: showPgReplicate, databaseRoles: showRoles } = useIsFeatureEnabled([ + 'database:replication', + 'database:roles', + 'integrations:wrappers', + ]) - const { data } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) const { data: addons } = useProjectAddonsQuery({ projectRef: project?.ref }) - const pgNetExtensionExists = (data ?? []).some((ext) => ext.name === 'pg_net') const pitrEnabled = addons?.selected_addons.some((addon) => addon.type === 'pitr') ?? false const columnLevelPrivileges = useIsColumnLevelPrivilegesEnabled() const enablePgReplicate = useIsETLPrivateAlpha() const getDatabaseURL = (path: string) => `/project/${ref}/database/${path}` - // In the new marketplace revamped page the `category=wrapper` query param has - // changed to `type=wrapper`. So fix this link below based on which version is - // the user viewing. - const isMarketplaceEnabled = useIsMarketplaceEnabled() - const wrappersLinkParamName = isMarketplaceEnabled ? 'type' : 'category' - return [ { title: 'Database Management', @@ -159,42 +141,7 @@ export const useGenerateDatabaseMenu = (): ProductMenuGroup[] => { url: getDatabaseURL('migrations'), shortcutId: SHORTCUT_IDS.NAV_DATABASE_MIGRATIONS, }, - showWrappers && { - name: 'Wrappers', - key: 'wrappers', - url: `/project/${ref}/integrations?${wrappersLinkParamName}=wrapper`, - rightIcon: ExternalLinkIcon, - }, - pgNetExtensionExists && { - name: 'Database Webhooks', - key: 'hooks', - url: `/project/${ref}/integrations/webhooks/overview`, - rightIcon: ExternalLinkIcon, - }, ].filter(Boolean) as ProductMenuGroupItem[], }, - { - title: 'Tools', - items: [ - { - name: 'Security Advisor', - key: 'security-advisor', - url: `/project/${ref}/advisors/security`, - rightIcon: ExternalLinkIcon, - }, - { - name: 'Performance Advisor', - key: 'performance-advisor', - url: `/project/${ref}/advisors/performance`, - rightIcon: ExternalLinkIcon, - }, - { - name: 'Query Performance', - key: 'query-performance', - url: `/project/${ref}/observability/query-performance`, - rightIcon: ExternalLinkIcon, - }, - ], - }, ] } diff --git a/apps/studio/components/ui/Charts/ReportSettings.tsx b/apps/studio/components/ui/Charts/ReportSettings.tsx index 2b1299c1b92..17248926ddf 100644 --- a/apps/studio/components/ui/Charts/ReportSettings.tsx +++ b/apps/studio/components/ui/Charts/ReportSettings.tsx @@ -1,6 +1,6 @@ import { Settings } from 'lucide-react' import { useState } from 'react' -import { cn, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger, Label, Switch } from 'ui' +import { Label, Popover, PopoverContent, PopoverTrigger, Switch } from 'ui' import { useChartHoverState } from './useChartHoverState' import { ButtonTooltip } from '@/components/ui/ButtonTooltip' @@ -14,46 +14,43 @@ export const ReportSettings = ({ chartId }: ReportSettingsProps) => { const { syncHover, syncTooltip, setSyncHover, setSyncTooltip } = useChartHoverState(chartId) return ( - - + + } className="w-7" tooltip={{ content: { side: 'bottom', text: 'Report settings' } }} /> - - -
- - -