- Replace legacy directory structure (api/, db/, functions/, logs/, pooler/) with single docker-compose.yml based self-hosted setup - Add selfhosted-supabase-mcp TypeScript MCP server for database management - Add .dockerignore for Docker build context - Update .gitignore to exclude .env files, volumes/, backups, logs
274 lines
12 KiB
TypeScript
274 lines
12 KiB
TypeScript
import { z } from 'zod';
|
|
import { executeSqlWithFallback, isSqlErrorResponse } from './utils.js';
|
|
import type { ToolContext } from './types.js';
|
|
|
|
// Advisor types
|
|
const AdvisorTypeSchema = z.enum(['security', 'performance']);
|
|
type AdvisorType = z.infer<typeof AdvisorTypeSchema>;
|
|
|
|
// Schema for advisor issue output
|
|
const AdvisorIssueSchema = z.object({
|
|
code: z.string(),
|
|
name: z.string(),
|
|
level: z.enum(['warning', 'error', 'info']),
|
|
description: z.string(),
|
|
detail: z.string().nullable(),
|
|
remediation: z.string().nullable(),
|
|
affected_object: z.string().nullable(),
|
|
});
|
|
const GetAdvisorsOutputSchema = z.object({
|
|
issues: z.array(AdvisorIssueSchema),
|
|
type: AdvisorTypeSchema,
|
|
total_count: z.number(),
|
|
});
|
|
type GetAdvisorsOutput = z.infer<typeof GetAdvisorsOutputSchema>;
|
|
|
|
// Input schema
|
|
const GetAdvisorsInputSchema = z.object({
|
|
type: AdvisorTypeSchema.describe('The type of advisors to retrieve (security or performance)'),
|
|
});
|
|
type GetAdvisorsInput = z.infer<typeof GetAdvisorsInputSchema>;
|
|
|
|
// Static JSON Schema for MCP capabilities
|
|
const mcpInputSchema = {
|
|
type: 'object',
|
|
properties: {
|
|
type: {
|
|
type: 'string',
|
|
enum: ['security', 'performance'],
|
|
description: 'The type of advisors to retrieve (security or performance)',
|
|
},
|
|
},
|
|
required: ['type'],
|
|
};
|
|
|
|
// SQL queries for security checks (ported from Supabase Splinter)
|
|
const securityChecks = {
|
|
// 0013 - RLS disabled in public schema
|
|
rls_disabled_in_public: `
|
|
SELECT
|
|
'0013' as code,
|
|
'rls_disabled_in_public' as name,
|
|
'warning' as level,
|
|
'Tables in the public schema without Row Level Security enabled' as description,
|
|
format('Table: %I.%I', n.nspname, c.relname) as detail,
|
|
'Enable RLS with: ALTER TABLE ' || quote_ident(n.nspname) || '.' || quote_ident(c.relname) || ' ENABLE ROW LEVEL SECURITY;' as remediation,
|
|
format('%I.%I', n.nspname, c.relname) as affected_object
|
|
FROM pg_catalog.pg_class c
|
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE c.relkind = 'r'
|
|
AND n.nspname = 'public'
|
|
AND NOT c.relrowsecurity
|
|
AND c.relname NOT LIKE 'pg_%'
|
|
AND c.relname NOT LIKE '_pg_%'
|
|
`,
|
|
|
|
// 0007 - Policy exists but RLS disabled
|
|
policy_exists_rls_disabled: `
|
|
SELECT
|
|
'0007' as code,
|
|
'policy_exists_rls_disabled' as name,
|
|
'warning' as level,
|
|
'Tables with RLS policies defined but RLS is disabled' as description,
|
|
format('Table: %I.%I has policies but RLS is disabled', n.nspname, c.relname) as detail,
|
|
'Enable RLS with: ALTER TABLE ' || quote_ident(n.nspname) || '.' || quote_ident(c.relname) || ' ENABLE ROW LEVEL SECURITY;' as remediation,
|
|
format('%I.%I', n.nspname, c.relname) as affected_object
|
|
FROM pg_catalog.pg_class c
|
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE c.relkind = 'r'
|
|
AND NOT c.relrowsecurity
|
|
AND EXISTS (
|
|
SELECT 1 FROM pg_catalog.pg_policy p WHERE p.polrelid = c.oid
|
|
)
|
|
`,
|
|
|
|
// 0008 - RLS enabled but no policy
|
|
rls_enabled_no_policy: `
|
|
SELECT
|
|
'0008' as code,
|
|
'rls_enabled_no_policy' as name,
|
|
'error' as level,
|
|
'Tables with RLS enabled but no policies defined (blocks all access)' as description,
|
|
format('Table: %I.%I has RLS enabled but no policies', n.nspname, c.relname) as detail,
|
|
'Add a policy or disable RLS if not needed' as remediation,
|
|
format('%I.%I', n.nspname, c.relname) as affected_object
|
|
FROM pg_catalog.pg_class c
|
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE c.relkind = 'r'
|
|
AND c.relrowsecurity
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM pg_catalog.pg_policy p WHERE p.polrelid = c.oid
|
|
)
|
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
|
`,
|
|
|
|
// 0002 - Auth users exposed via view
|
|
auth_users_exposed: `
|
|
SELECT
|
|
'0002' as code,
|
|
'auth_users_exposed' as name,
|
|
'error' as level,
|
|
'Views exposing auth.users data' as description,
|
|
format('View: %I.%I may expose auth.users', n.nspname, c.relname) as detail,
|
|
'Review and restrict the view definition or add proper RLS' as remediation,
|
|
format('%I.%I', n.nspname, c.relname) as affected_object
|
|
FROM pg_catalog.pg_class c
|
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
JOIN pg_catalog.pg_depend d ON d.objid = c.oid
|
|
JOIN pg_catalog.pg_class dep_c ON dep_c.oid = d.refobjid
|
|
JOIN pg_catalog.pg_namespace dep_n ON dep_n.oid = dep_c.relnamespace
|
|
WHERE c.relkind IN ('v', 'm')
|
|
AND n.nspname = 'public'
|
|
AND dep_n.nspname = 'auth'
|
|
AND dep_c.relname = 'users'
|
|
`,
|
|
};
|
|
|
|
// SQL queries for performance checks (ported from Supabase Splinter)
|
|
const performanceChecks = {
|
|
// 0001 - Unindexed foreign keys
|
|
unindexed_foreign_keys: `
|
|
SELECT
|
|
'0001' as code,
|
|
'unindexed_foreign_keys' as name,
|
|
'warning' as level,
|
|
'Foreign keys without covering indexes can impact performance' as description,
|
|
format('FK on %I.%I (%s) lacks an index', cn.nspname, c.conrelid::regclass::text,
|
|
array_to_string(ARRAY(
|
|
SELECT a.attname FROM unnest(c.conkey) WITH ORDINALITY AS u(attnum, i)
|
|
JOIN pg_attribute a ON a.attnum = u.attnum AND a.attrelid = c.conrelid
|
|
ORDER BY u.i
|
|
), ', ')) as detail,
|
|
'Create an index on the foreign key columns' as remediation,
|
|
c.conrelid::regclass::text as affected_object
|
|
FROM pg_catalog.pg_constraint c
|
|
JOIN pg_catalog.pg_namespace cn ON cn.oid = c.connamespace
|
|
WHERE c.contype = 'f'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM pg_catalog.pg_index i
|
|
WHERE i.indrelid = c.conrelid
|
|
AND c.conkey <@ i.indkey::int2[]
|
|
)
|
|
AND cn.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
`,
|
|
|
|
// 0004 - Missing primary keys
|
|
missing_primary_keys: `
|
|
SELECT
|
|
'0004' as code,
|
|
'missing_primary_key' as name,
|
|
'warning' as level,
|
|
'Tables without primary keys are inefficient at scale' as description,
|
|
format('Table: %I.%I has no primary key', n.nspname, c.relname) as detail,
|
|
'Add a primary key to the table' as remediation,
|
|
format('%I.%I', n.nspname, c.relname) as affected_object
|
|
FROM pg_catalog.pg_class c
|
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE c.relkind = 'r'
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM pg_catalog.pg_constraint con
|
|
WHERE con.conrelid = c.oid AND con.contype = 'p'
|
|
)
|
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'cron', 'extensions', 'graphql', 'graphql_public', 'realtime', 'storage', 'supabase_functions', 'supabase_migrations', 'vault', 'pgsodium', 'pgsodium_masks', 'auth', 'net', '_realtime')
|
|
`,
|
|
|
|
// 0005 - Unused indexes
|
|
unused_indexes: `
|
|
SELECT
|
|
'0005' as code,
|
|
'unused_index' as name,
|
|
'info' as level,
|
|
'Indexes with zero scans that may be candidates for removal' as description,
|
|
format('Index: %I.%I on %I.%I has had 0 scans', sn.nspname, i.relname, tn.nspname, t.relname) as detail,
|
|
'Consider dropping the index if it is not needed' as remediation,
|
|
format('%I.%I', sn.nspname, i.relname) as affected_object
|
|
FROM pg_catalog.pg_stat_user_indexes s
|
|
JOIN pg_catalog.pg_index ix ON ix.indexrelid = s.indexrelid
|
|
JOIN pg_catalog.pg_class i ON i.oid = s.indexrelid
|
|
JOIN pg_catalog.pg_class t ON t.oid = s.relid
|
|
JOIN pg_catalog.pg_namespace sn ON sn.oid = i.relnamespace
|
|
JOIN pg_catalog.pg_namespace tn ON tn.oid = t.relnamespace
|
|
WHERE s.idx_scan = 0
|
|
AND NOT ix.indisunique
|
|
AND NOT ix.indisprimary
|
|
AND sn.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
|
`,
|
|
|
|
// 0009 - Duplicate indexes
|
|
duplicate_indexes: `
|
|
SELECT
|
|
'0009' as code,
|
|
'duplicate_index' as name,
|
|
'warning' as level,
|
|
'Duplicate indexes waste storage and slow writes' as description,
|
|
format('Indexes %I and %I on %I.%I have identical definitions',
|
|
i1.relname, i2.relname, tn.nspname, t.relname) as detail,
|
|
'Consider dropping one of the duplicate indexes' as remediation,
|
|
format('%I.%I', sn.nspname, i1.relname) as affected_object
|
|
FROM pg_catalog.pg_index x1
|
|
JOIN pg_catalog.pg_index x2 ON x1.indrelid = x2.indrelid AND x1.indexrelid < x2.indexrelid
|
|
JOIN pg_catalog.pg_class i1 ON i1.oid = x1.indexrelid
|
|
JOIN pg_catalog.pg_class i2 ON i2.oid = x2.indexrelid
|
|
JOIN pg_catalog.pg_class t ON t.oid = x1.indrelid
|
|
JOIN pg_catalog.pg_namespace sn ON sn.oid = i1.relnamespace
|
|
JOIN pg_catalog.pg_namespace tn ON tn.oid = t.relnamespace
|
|
WHERE x1.indkey = x2.indkey
|
|
AND x1.indclass = x2.indclass
|
|
AND x1.indoption = x2.indoption
|
|
AND sn.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast')
|
|
`,
|
|
};
|
|
|
|
// Tool definition
|
|
export const getAdvisorsTool = {
|
|
name: 'get_advisors',
|
|
description: 'Gets security or performance advisory notices for the database. Based on Supabase Splinter linting rules. Helps identify issues like missing RLS policies, unindexed foreign keys, and other common problems.',
|
|
inputSchema: GetAdvisorsInputSchema,
|
|
mcpInputSchema: mcpInputSchema,
|
|
outputSchema: GetAdvisorsOutputSchema,
|
|
execute: async (input: GetAdvisorsInput, context: ToolContext): Promise<GetAdvisorsOutput> => {
|
|
const client = context.selfhostedClient;
|
|
const { type } = input;
|
|
|
|
const checks = type === 'security' ? securityChecks : performanceChecks;
|
|
const allIssues: z.infer<typeof AdvisorIssueSchema>[] = [];
|
|
|
|
for (const [checkName, sql] of Object.entries(checks)) {
|
|
try {
|
|
const result = await executeSqlWithFallback(client, sql, true);
|
|
|
|
if (isSqlErrorResponse(result)) {
|
|
context.log(`Error running ${checkName}: ${result.error.message}`, 'warn');
|
|
continue;
|
|
}
|
|
|
|
if (Array.isArray(result)) {
|
|
for (const row of result) {
|
|
allIssues.push({
|
|
code: String(row.code || ''),
|
|
name: String(row.name || checkName),
|
|
level: (row.level as 'warning' | 'error' | 'info' | undefined) ?? 'warning',
|
|
description: String(row.description || ''),
|
|
detail: row.detail ? String(row.detail) : null,
|
|
remediation: row.remediation ? String(row.remediation) : null,
|
|
affected_object: row.affected_object ? String(row.affected_object) : null,
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
context.log(`Failed to run ${checkName}: ${error}`, 'warn');
|
|
}
|
|
}
|
|
|
|
// Sort by level (error first, then warning, then info)
|
|
const levelOrder = { error: 0, warning: 1, info: 2 };
|
|
allIssues.sort((a, b) => levelOrder[a.level] - levelOrder[b.level]);
|
|
|
|
return {
|
|
issues: allIssues,
|
|
type,
|
|
total_count: allIssues.length,
|
|
};
|
|
},
|
|
};
|