mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
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
This commit is contained in:
1 parent
d5d8d954cb
commit
31b6368049
15 files changed
+83
-22
No files matched your search
@@ -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.
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -23,8 +23,8 @@ export function getDatabaseOperations({
|
||||
}: GetDatabaseOperationsOptions): DatabaseOperations {
|
||||
return {
|
||||
async executeSql<T>(_projectRef: string, options: ExecuteSqlOptions) {
|
||||
const { query } = options
|
||||
const { data, error } = await executeQuery<T>({ query, headers })
|
||||
const { query, read_only: readOnly } = options
|
||||
const { data, error } = await executeQuery<T>({ query, headers, readOnly })
|
||||
|
||||
if (error) {
|
||||
throw error
|
||||
|
||||
@@ -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<T = unknown>({
|
||||
query,
|
||||
readOnly = false,
|
||||
headers,
|
||||
}: QueryOptions): Promise<WrappedResult<T[]>> {
|
||||
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 }),
|
||||
})
|
||||
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
############
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -34,19 +34,17 @@ export function McpConfigurationOptions({
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-4 lg:flex-row lg:gap-12 lg:items-baseline', className)}>
|
||||
{/* Readonly Mode */}
|
||||
{isPlatform && (
|
||||
<div className="space-y-3 lg:flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="readonly" className="text-sm">
|
||||
Read-only
|
||||
</Label>
|
||||
<InfoTooltip>Only allow read operations on your database</InfoTooltip>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="readonly" checked={readonly} onCheckedChange={onReadonlyChange} />
|
||||
</div>
|
||||
<div className="space-y-3 lg:flex-shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="readonly" className="text-sm">
|
||||
Read-only
|
||||
</Label>
|
||||
<InfoTooltip>Only allow read operations on your database</InfoTooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch id="readonly" checked={readonly} onCheckedChange={onReadonlyChange} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feature Groups */}
|
||||
<div className="space-y-3 lg:flex-1">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Generated
+6
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user