From 31b6368049a1d9f5ef409383567b3f9bdaf018e9 Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Mon, 6 Oct 2025 16:14:27 -0400 Subject: [PATCH] feat: read-only mode for self-hosted MCP (#39041) * feat: add `crypto-js`, `encryptString` with sample key * feat: include POSTGRES_PASSWORD in generated .env.test * feat: include POSTGRES_PASSWORD in turbo.json for studio * feat: read only query support * feat: configurable `POSTGRES_HOST`, `POSTGRES_DB`, `POSTGRES_PORT` * chore: rename POSTGRES_USER to clarify write permission * feat: configurable `PG_META_CRYPTO_KEY` * chore: add `PG_META_CRYPTO_KEY` to generateLocalEnv * feat: add 'postgres-meta' to linter dictionary * feat: restore read-only toggle in local MCP URL builder --- .../content/guides/self-hosting/docker.mdx | 1 + apps/studio/lib/api/self-hosted/constants.ts | 9 ++++++++ apps/studio/lib/api/self-hosted/mcp.ts | 4 ++-- apps/studio/lib/api/self-hosted/query.ts | 14 ++++++++---- apps/studio/lib/api/self-hosted/util.ts | 20 +++++++++++++++++ apps/studio/package.json | 2 ++ apps/studio/pages/api/mcp/index.ts | 10 +++++++-- docker/.env.example | 1 + docker/docker-compose.yml | 2 ++ .../components/McpConfigurationOptions.tsx | 22 +++++++++---------- .../src/McpUrlBuilder/utils/getMcpUrl.ts | 2 +- pnpm-lock.yaml | 6 +++++ scripts/generateLocalEnv.js | 6 ++++- supa-mdx-lint/Rule003Spelling.toml | 1 + turbo.json | 5 +++++ 15 files changed, 83 insertions(+), 22 deletions(-) create mode 100644 apps/studio/lib/api/self-hosted/constants.ts diff --git a/apps/docs/content/guides/self-hosting/docker.mdx b/apps/docs/content/guides/self-hosting/docker.mdx index 6f4210253b7..a704a2ef93e 100644 --- a/apps/docs/content/guides/self-hosting/docker.mdx +++ b/apps/docs/content/guides/self-hosting/docker.mdx @@ -220,6 +220,7 @@ Update the `./docker/.env` file with your own secrets. In particular, these are - `SITE_URL`: the base URL of your site. - `SMTP_*`: mail server credentials. You can use any SMTP server. - `POOLER_TENANT_ID`: the tenant-id that will be used by Supavisor pooler for your connection string +- `PG_META_CRYPTO_KEY`: encryption key for securing connection strings between Studio and postgres-meta You will need to [restart](#restarting-all-services) the services for the changes to take effect. diff --git a/apps/studio/lib/api/self-hosted/constants.ts b/apps/studio/lib/api/self-hosted/constants.ts new file mode 100644 index 00000000000..8d0f2dfa47d --- /dev/null +++ b/apps/studio/lib/api/self-hosted/constants.ts @@ -0,0 +1,9 @@ +// Constants specific to self-hosted environments + +export const ENCRYPTION_KEY = process.env.PG_META_CRYPTO_KEY || 'SAMPLE_KEY' +export const POSTGRES_PORT = process.env.POSTGRES_PORT || 5432 +export const POSTGRES_HOST = process.env.POSTGRES_HOST || 'db' +export const POSTGRES_DATABASE = process.env.POSTGRES_DB || 'postgres' +export const POSTGRES_PASSWORD = process.env.POSTGRES_PASSWORD || 'postgres' +export const POSTGRES_USER_READ_WRITE = 'postgres' +export const POSTGRES_USER_READ_ONLY = 'supabase_read_only_user' diff --git a/apps/studio/lib/api/self-hosted/mcp.ts b/apps/studio/lib/api/self-hosted/mcp.ts index ea9722f5f86..79ecae3dac7 100644 --- a/apps/studio/lib/api/self-hosted/mcp.ts +++ b/apps/studio/lib/api/self-hosted/mcp.ts @@ -23,8 +23,8 @@ export function getDatabaseOperations({ }: GetDatabaseOperationsOptions): DatabaseOperations { return { async executeSql(_projectRef: string, options: ExecuteSqlOptions) { - const { query } = options - const { data, error } = await executeQuery({ query, headers }) + const { query, read_only: readOnly } = options + const { data, error } = await executeQuery({ query, headers, readOnly }) if (error) { throw error diff --git a/apps/studio/lib/api/self-hosted/query.ts b/apps/studio/lib/api/self-hosted/query.ts index bcceb08ee34..9834e52245a 100644 --- a/apps/studio/lib/api/self-hosted/query.ts +++ b/apps/studio/lib/api/self-hosted/query.ts @@ -1,10 +1,11 @@ import { PG_META_URL } from 'lib/constants/index' import { constructHeaders } from '../apiHelpers' import { PgMetaDatabaseError, databaseErrorSchema, WrappedResult } from './types' -import { assertSelfHosted } from './util' +import { assertSelfHosted, encryptString, getConnectionString } from './util' export type QueryOptions = { query: string + readOnly?: boolean headers?: HeadersInit } @@ -15,16 +16,21 @@ export type QueryOptions = { */ export async function executeQuery({ query, + readOnly = false, headers, }: QueryOptions): Promise> { assertSelfHosted() + const connectionString = getConnectionString({ readOnly }) + const connectionStringEncrypted = encryptString(connectionString) + const response = await fetch(`${PG_META_URL}/query`, { method: 'POST', - headers: { + headers: constructHeaders({ + ...headers, 'Content-Type': 'application/json', - ...constructHeaders(headers ?? {}), - }, + 'x-connection-encrypted': connectionStringEncrypted, + }), body: JSON.stringify({ query }), }) diff --git a/apps/studio/lib/api/self-hosted/util.ts b/apps/studio/lib/api/self-hosted/util.ts index ae5ba237105..54fd5f6d6b1 100644 --- a/apps/studio/lib/api/self-hosted/util.ts +++ b/apps/studio/lib/api/self-hosted/util.ts @@ -1,4 +1,14 @@ +import crypto from 'crypto-js' import { IS_PLATFORM } from 'lib/constants' +import { + ENCRYPTION_KEY, + POSTGRES_DATABASE, + POSTGRES_HOST, + POSTGRES_PASSWORD, + POSTGRES_PORT, + POSTGRES_USER_READ_WRITE, + POSTGRES_USER_READ_ONLY, +} from './constants' /** * Asserts that the current environment is self-hosted. @@ -8,3 +18,13 @@ export function assertSelfHosted() { throw new Error('This function can only be called in self-hosted environments') } } + +export function encryptString(stringToEncrypt: string): string { + return crypto.AES.encrypt(stringToEncrypt, ENCRYPTION_KEY).toString() +} + +export function getConnectionString({ readOnly }: { readOnly: boolean }) { + const postgresUser = readOnly ? POSTGRES_USER_READ_ONLY : POSTGRES_USER_READ_WRITE + + return `postgresql://${postgresUser}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DATABASE}` +} diff --git a/apps/studio/package.json b/apps/studio/package.json index 96ee7253159..0c9ff9aa0ad 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -79,6 +79,7 @@ "config": "workspace:*", "cron-parser": "^4.9.0", "cronstrue": "^2.50.0", + "crypto-js": "^4.2.0", "dayjs": "^1.11.10", "dnd-core": "^16.0.1", "file-saver": "^2.0.5", @@ -159,6 +160,7 @@ "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.0.0", "@types/common-tags": "^1.8.1", + "@types/crypto-js": "^4.2.2", "@types/file-saver": "^2.0.2", "@types/json-logic-js": "^1.2.1", "@types/lodash": "^4.14.172", diff --git a/apps/studio/pages/api/mcp/index.ts b/apps/studio/pages/api/mcp/index.ts index c2ed15f8bcd..6cf25d15437 100644 --- a/apps/studio/pages/api/mcp/index.ts +++ b/apps/studio/pages/api/mcp/index.ts @@ -1,7 +1,7 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import { createSupabaseMcpServer, SupabasePlatform } from '@supabase/mcp-server-supabase' import { stripIndent } from 'common-tags' -import { commaSeparatedStringIntoArray, fromNodeHeaders } from 'lib/api/apiHelpers' +import { commaSeparatedStringIntoArray, fromNodeHeaders, zBooleanString } from 'lib/api/apiHelpers' import { getDatabaseOperations, getDevelopmentOperations } from 'lib/api/self-hosted/mcp' import { DEFAULT_PROJECT } from 'lib/constants/api' import { NextApiRequest, NextApiResponse } from 'next' @@ -22,6 +22,11 @@ const mcpQuerySchema = z.object({ ` ) .pipe(z.array(supportedFeatureGroupSchema).optional()), + read_only: zBooleanString() + .default('false') + .describe( + 'Indicates whether or not the MCP server should operate in read-only mode. This prevents write operations on any of your databases by executing SQL as a read-only Postgres user.' + ), }) const handler = async (req: NextApiRequest, res: NextApiResponse) => { @@ -41,7 +46,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { return res.status(400).json({ error: error.flatten().fieldErrors }) } - const { features } = data + const { features, read_only } = data const headers = fromNodeHeaders(req.headers) const platform: SupabasePlatform = { @@ -54,6 +59,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse) { platform, projectId: DEFAULT_PROJECT.ref, features, + readOnly: read_only, }) const transport = new StreamableHTTPServerTransport({ diff --git a/docker/.env.example b/docker/.env.example index 8ee5f75c680..63f2b2702a5 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -11,6 +11,7 @@ DASHBOARD_USERNAME=supabase DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq VAULT_ENC_KEY=your-encryption-key-32-chars-min +PG_META_CRYPTO_KEY=your-encryption-key-32-chars-min ############ diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9361659aed6..6d9192bd593 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -30,6 +30,7 @@ services: environment: STUDIO_PG_META_URL: http://meta:8080 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + PG_META_CRYPTO_KEY: ${PG_META_CRYPTO_KEY} DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION} DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT} @@ -311,6 +312,7 @@ services: PG_META_DB_NAME: ${POSTGRES_DB} PG_META_DB_USER: supabase_admin PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} + CRYPTO_KEY: ${PG_META_CRYPTO_KEY} functions: container_name: supabase-edge-functions diff --git a/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationOptions.tsx b/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationOptions.tsx index 7d71e8a8f91..ad0dccaf2ab 100644 --- a/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationOptions.tsx +++ b/packages/ui-patterns/src/McpUrlBuilder/components/McpConfigurationOptions.tsx @@ -34,19 +34,17 @@ export function McpConfigurationOptions({ return (
{/* Readonly Mode */} - {isPlatform && ( -
-
- - Only allow read operations on your database -
-
- -
+
+
+ + Only allow read operations on your database
- )} +
+ +
+
{/* Feature Groups */}
diff --git a/packages/ui-patterns/src/McpUrlBuilder/utils/getMcpUrl.ts b/packages/ui-patterns/src/McpUrlBuilder/utils/getMcpUrl.ts index 94dc41f8cb7..e7ff07dad69 100644 --- a/packages/ui-patterns/src/McpUrlBuilder/utils/getMcpUrl.ts +++ b/packages/ui-patterns/src/McpUrlBuilder/utils/getMcpUrl.ts @@ -28,7 +28,7 @@ export function getMcpUrl({ if (projectRef && isPlatform) { url.searchParams.set('project_ref', projectRef) } - if (readonly && isPlatform) { + if (readonly) { url.searchParams.set('read_only', 'true') } if (features.length > 0) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39d4eabbd60..d8c9f943efa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -876,6 +876,9 @@ importers: cronstrue: specifier: ^2.50.0 version: 2.50.0 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 dayjs: specifier: ^1.11.10 version: 1.11.13 @@ -1111,6 +1114,9 @@ importers: '@types/common-tags': specifier: ^1.8.1 version: 1.8.4 + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 '@types/file-saver': specifier: ^2.0.2 version: 2.0.5 diff --git a/scripts/generateLocalEnv.js b/scripts/generateLocalEnv.js index ac7a488c72d..d4134e5d539 100644 --- a/scripts/generateLocalEnv.js +++ b/scripts/generateLocalEnv.js @@ -8,11 +8,15 @@ const generatedEnv = require('../keys.json') */ const defaultEnv = { - // POSTGRES_PASSWORD: 'postgres', // NEXT_ANALYTICS_BACKEND_PROVIDER: 'postgres', // SUPABASE_REST_URL: 'http://127.0.0.1:54321/rest/v1/', // NEXT_PUBLIC_ENABLE_LOGS: 'false', // NEXT_PUBLIC_IS_PLATFORM: 'false', + PG_META_CRYPTO_KEY: 'SAMPLE_KEY', + POSTGRES_PASSWORD: 'postgres', + POSTGRES_HOST: 'db', + POSTGRES_DB: 'postgres', + POSTGRES_PORT: '5432', SUPABASE_ANON_KEY: '$ANON_KEY', SUPABASE_SERVICE_KEY: '$SERVICE_ROLE_KEY', SUPABASE_URL: '$API_URL', diff --git a/supa-mdx-lint/Rule003Spelling.toml b/supa-mdx-lint/Rule003Spelling.toml index 91a877b738e..76d95a92010 100644 --- a/supa-mdx-lint/Rule003Spelling.toml +++ b/supa-mdx-lint/Rule003Spelling.toml @@ -246,6 +246,7 @@ allow_list = [ "PostQUEL", "PostgREST", "Postgres", + "postgres-meta", # We prefer Postgres, but check for vocabulary preference in a separate rule "PostgreSQL", "ProGuard", diff --git a/turbo.json b/turbo.json index a7c33bf5533..7dd90e5bbb2 100644 --- a/turbo.json +++ b/turbo.json @@ -82,6 +82,11 @@ // These envs are technically passthrough env vars because they're only used on the server side of Nextjs "PLATFORM_PG_META_URL", "STUDIO_PG_META_URL", + "PG_META_CRYPTO_KEY", + "POSTGRES_PASSWORD", + "POSTGRES_HOST", + "POSTGRES_DB", + "POSTGRES_PORT", "READ_ONLY_URL", "READ_ONLY_API_KEY", "SUPABASE_SERVICE_KEY",