From 59d0d216de877ae95edb9669c1b6848e3845db22 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Jan 2026 23:14:19 +0000 Subject: [PATCH 01/16] feat(mcp): add drive scoping for MCP tokens Allow MCP tokens to be optionally scoped to specific drives, reducing cross-contamination and limiting access for coding agents. Changes: - Add mcp_token_drives junction table for drive scopes - Update token creation API to accept optional driveIds array - Update token validation to return allowedDriveIds - Filter drives list and document access by token scope - Add drive selector UI in MCP settings token creation dialog - Show drive scope badges on existing tokens When a token has no drive scopes, it has access to all user drives (backward compatible). When scoped, it can only access those drives. https://claude.ai/code/session_013UGoFEMg858nTDbUVCBMqt --- .../app/api/auth/__tests__/mcp-tokens.test.ts | 2 + apps/web/src/app/api/auth/mcp-tokens/route.ts | 70 +++++++++- apps/web/src/app/api/mcp/documents/route.ts | 40 +++++- apps/web/src/app/api/mcp/drives/route.ts | 40 +++++- .../settings/mcp/MCPSettingsView.tsx | 132 ++++++++++++++++-- .../auth/__tests__/auth-middleware.test.ts | 16 +++ apps/web/src/lib/auth/index.ts | 12 ++ packages/db/drizzle/0057_mcp_token_drives.sql | 29 ++++ packages/db/src/index.ts | 2 + packages/db/src/schema/auth.ts | 32 ++++- 10 files changed, 352 insertions(+), 23 deletions(-) create mode 100644 packages/db/drizzle/0057_mcp_token_drives.sql diff --git a/apps/web/src/app/api/auth/__tests__/mcp-tokens.test.ts b/apps/web/src/app/api/auth/__tests__/mcp-tokens.test.ts index f08ccc3bb..5aad01638 100644 --- a/apps/web/src/app/api/auth/__tests__/mcp-tokens.test.ts +++ b/apps/web/src/app/api/auth/__tests__/mcp-tokens.test.ts @@ -332,12 +332,14 @@ describe('/api/auth/mcp-tokens', () => { name: 'Token 1', lastUsed: new Date(), createdAt: new Date(), + driveScopes: [{ driveId: 'drive-1', drive: { id: 'drive-1', name: 'Work Drive' } }], }, { id: 'token-2', name: 'Token 2', lastUsed: null, createdAt: new Date(), + driveScopes: [], }, ]); }); diff --git a/apps/web/src/app/api/auth/mcp-tokens/route.ts b/apps/web/src/app/api/auth/mcp-tokens/route.ts index 8cd38c866..d026b37b6 100644 --- a/apps/web/src/app/api/auth/mcp-tokens/route.ts +++ b/apps/web/src/app/api/auth/mcp-tokens/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { db, mcpTokens } from '@pagespace/db'; +import { db, mcpTokens, mcpTokenDrives, drives, eq, and, inArray } from '@pagespace/db'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { z } from 'zod/v4'; import { loggers } from '@pagespace/lib/server'; @@ -12,6 +12,9 @@ const AUTH_OPTIONS_WRITE = { allow: ['session'] as const, requireCSRF: true }; // Schema for creating a new MCP token const createTokenSchema = z.object({ name: z.string().min(1).max(100), + // Optional array of drive IDs to scope this token to + // If empty or not provided, token has access to all user's drives + driveIds: z.array(z.string()).optional(), }); // POST: Create a new MCP token @@ -22,7 +25,28 @@ export async function POST(req: NextRequest) { try { const body = await req.json(); - const { name } = createTokenSchema.parse(body); + const { name, driveIds } = createTokenSchema.parse(body); + + // Validate that the user owns/has access to the specified drives + if (driveIds && driveIds.length > 0) { + const userDrives = await db.query.drives.findMany({ + where: and( + eq(drives.ownerId, userId), + inArray(drives.id, driveIds) + ), + columns: { id: true }, + }); + + const validDriveIds = new Set(userDrives.map(d => d.id)); + const invalidDriveIds = driveIds.filter(id => !validDriveIds.has(id)); + + if (invalidDriveIds.length > 0) { + return NextResponse.json( + { error: 'Invalid drive IDs: ' + invalidDriveIds.join(', ') }, + { status: 400 } + ); + } + } // P1-T3: Generate token with hash and prefix for secure storage // SECURITY: Only the hash is stored - plaintext token is returned once and never persisted @@ -36,6 +60,16 @@ export async function POST(req: NextRequest) { name, }).returning(); + // If drive scopes are specified, create the junction table entries + if (driveIds && driveIds.length > 0) { + await db.insert(mcpTokenDrives).values( + driveIds.map(driveId => ({ + tokenId: newToken.id, + driveId, + })) + ); + } + // Log activity for audit trail (token creation is a security event) const actorInfo = await getActorInfo(userId); logTokenActivity(userId, 'token_create', { @@ -50,6 +84,7 @@ export async function POST(req: NextRequest) { name: newToken.name, token: rawToken, // Return the actual token, not the hash createdAt: newToken.createdAt, + driveIds: driveIds || [], // Return the drive scopes }); } catch (error) { loggers.auth.error('Error creating MCP token:', error as Error); @@ -67,7 +102,7 @@ export async function GET(req: NextRequest) { const userId = auth.userId; try { - // Fetch all non-revoked tokens for the user + // Fetch all non-revoked tokens for the user with their drive scopes const tokens = await db.query.mcpTokens.findMany({ where: (tokens, { eq, isNull, and }) => and( eq(tokens.userId, userId), @@ -79,9 +114,36 @@ export async function GET(req: NextRequest) { lastUsed: true, createdAt: true, }, + with: { + driveScopes: { + columns: { + driveId: true, + }, + with: { + drive: { + columns: { + id: true, + name: true, + }, + }, + }, + }, + }, }); - return NextResponse.json(tokens); + // Transform the response to include drive info + const tokensWithDrives = tokens.map(token => ({ + id: token.id, + name: token.name, + lastUsed: token.lastUsed, + createdAt: token.createdAt, + driveScopes: token.driveScopes.map(scope => ({ + id: scope.drive.id, + name: scope.drive.name, + })), + })); + + return NextResponse.json(tokensWithDrives); } catch (error) { loggers.auth.error('Error fetching MCP tokens:', error as Error); return NextResponse.json({ error: 'Failed to fetch MCP tokens' }, { status: 500 }); diff --git a/apps/web/src/app/api/mcp/documents/route.ts b/apps/web/src/app/api/mcp/documents/route.ts index 17cf57a74..f1a500678 100644 --- a/apps/web/src/app/api/mcp/documents/route.ts +++ b/apps/web/src/app/api/mcp/documents/route.ts @@ -5,7 +5,7 @@ import { z } from 'zod/v4'; import prettier from 'prettier'; import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket'; import { loggers } from '@pagespace/lib/server'; -import { authenticateMCPRequest, isAuthError } from '@/lib/auth'; +import { authenticateMCPRequest, isAuthError, isMCPAuthResult } from '@/lib/auth'; import { getActorInfo } from '@pagespace/lib/monitoring/activity-logger'; import { applyPageMutation, PageRevisionMismatchError } from '@/services/api/page-mutation-service'; @@ -98,17 +98,49 @@ export async function POST(req: NextRequest) { } const userId = auth.userId; + // Get allowed drive IDs from token scope (empty means no restrictions) + let allowedDriveIds: string[] = []; + if (isMCPAuthResult(auth)) { + allowedDriveIds = auth.allowedDriveIds; + } + try { const body = await req.json(); const { operation, pageId: providedPageId, startLine, endLine, content, cells } = lineOperationSchema.parse(body); - + // Get the page ID (use provided or get current) const pageId = providedPageId || await getCurrentPageId(userId); - + if (!pageId) { return NextResponse.json({ error: 'No active document found' }, { status: 404 }); } - + + // Check drive scope restrictions before permission check + if (allowedDriveIds.length > 0) { + // Get the page's drive ID to check scope + const pageInfo = await db.query.pages.findFirst({ + where: eq(pages.id, pageId), + columns: { driveId: true }, + }); + + if (!pageInfo) { + return NextResponse.json({ error: 'Page not found' }, { status: 404 }); + } + + if (!allowedDriveIds.includes(pageInfo.driveId)) { + loggers.api.warn('MCP document access denied - drive not in token scope', { + userId, + pageId, + pageDriveId: pageInfo.driveId, + allowedDriveIds, + }); + return NextResponse.json( + { error: 'This token does not have access to this drive' }, + { status: 403 } + ); + } + } + // Check user permissions - Zero Trust: explicitly verify view permission const accessLevel = await getUserAccessLevel(userId, pageId); if (!accessLevel || !accessLevel.canView) { diff --git a/apps/web/src/app/api/mcp/drives/route.ts b/apps/web/src/app/api/mcp/drives/route.ts index 284c8c880..47ec59863 100644 --- a/apps/web/src/app/api/mcp/drives/route.ts +++ b/apps/web/src/app/api/mcp/drives/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; -import { db, drives, eq } from '@pagespace/db'; +import { db, drives, eq, inArray, and } from '@pagespace/db'; import { z } from 'zod/v4'; import { slugify } from '@pagespace/lib/server'; import { broadcastDriveEvent, createDriveEventPayload } from '@/lib/websocket'; import { loggers } from '@pagespace/lib/server'; -import { authenticateMCPRequest, isAuthError } from '@/lib/auth'; +import { authenticateMCPRequest, isAuthError, isMCPAuthResult } from '@/lib/auth'; import { getActorInfo, logDriveActivity } from '@pagespace/lib/monitoring/activity-logger'; // Schema for drive creation @@ -18,6 +18,15 @@ export async function POST(req: NextRequest) { return auth.error; } + // Check if this MCP token has drive scope restrictions + // Scoped tokens cannot create new drives (they only have access to specific drives) + if (isMCPAuthResult(auth) && auth.allowedDriveIds.length > 0) { + return NextResponse.json( + { error: 'This token is scoped to specific drives and cannot create new drives' }, + { status: 403 } + ); + } + try { const userId = auth.userId; const body = await req.json(); @@ -75,10 +84,29 @@ export async function GET(req: NextRequest) { try { const userId = auth.userId; - // Get user's drives - const userDrives = await db.query.drives.findMany({ - where: eq(drives.ownerId, userId), - }); + + // Check if this MCP token has drive scope restrictions + let allowedDriveIds: string[] = []; + if (isMCPAuthResult(auth)) { + allowedDriveIds = auth.allowedDriveIds; + } + + // Get user's drives, filtered by token scope if applicable + let userDrives; + if (allowedDriveIds.length > 0) { + // Token is scoped to specific drives - only return those + userDrives = await db.query.drives.findMany({ + where: and( + eq(drives.ownerId, userId), + inArray(drives.id, allowedDriveIds) + ), + }); + } else { + // Token has no scope restrictions - return all user's drives + userDrives = await db.query.drives.findMany({ + where: eq(drives.ownerId, userId), + }); + } return NextResponse.json(userDrives); } catch (error) { diff --git a/apps/web/src/components/layout/middle-content/page-views/settings/mcp/MCPSettingsView.tsx b/apps/web/src/components/layout/middle-content/page-views/settings/mcp/MCPSettingsView.tsx index 98a7b114c..dd47967be 100644 --- a/apps/web/src/components/layout/middle-content/page-views/settings/mcp/MCPSettingsView.tsx +++ b/apps/web/src/components/layout/middle-content/page-views/settings/mcp/MCPSettingsView.tsx @@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; import { Dialog, DialogContent, @@ -19,30 +20,44 @@ import { Alert, AlertDescription, } from '@/components/ui/alert'; -import { Trash2, Copy, Plus, Eye, EyeOff, Key, Terminal, Check, Download, AlertTriangle, ArrowLeft } from 'lucide-react'; +import { Trash2, Copy, Plus, Eye, EyeOff, Key, Terminal, Check, Download, AlertTriangle, ArrowLeft, Shield } from 'lucide-react'; import { toast } from 'sonner'; import { formatDistanceToNow } from 'date-fns'; import { useRouter } from 'next/navigation'; import { post, del, fetchWithAuth } from '@/lib/auth/auth-fetch'; +interface DriveScope { + id: string; + name: string; +} + interface MCPToken { id: string; name: string; lastUsed: string | null; createdAt: string; + driveScopes: DriveScope[]; } interface NewToken extends MCPToken { token: string; } +interface Drive { + id: string; + name: string; + slug: string; +} + export default function MCPSettingsView() { const router = useRouter(); const [tokens, setTokens] = useState([]); + const [drives, setDrives] = useState([]); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [newTokenName, setNewTokenName] = useState(''); + const [selectedDriveIds, setSelectedDriveIds] = useState([]); const [newToken, setNewToken] = useState(null); const [showNewToken, setShowNewToken] = useState(false); const [copied, setCopied] = useState(false); @@ -51,6 +66,7 @@ export default function MCPSettingsView() { useEffect(() => { loadTokens(); + loadDrives(); }, []); const loadTokens = async () => { @@ -70,6 +86,18 @@ export default function MCPSettingsView() { } }; + const loadDrives = async () => { + try { + const response = await fetchWithAuth('/api/drives'); + if (response.ok) { + const driveList = await response.json(); + setDrives(driveList); + } + } catch (error) { + console.error('Error loading drives:', error); + } + }; + const createToken = async () => { if (!newTokenName.trim()) { toast.error('Please enter a name for the token'); @@ -78,16 +106,31 @@ export default function MCPSettingsView() { setCreating(true); try { - const token = await post('/api/auth/mcp-tokens', { name: newTokenName.trim() }); + const payload: { name: string; driveIds?: string[] } = { name: newTokenName.trim() }; + if (selectedDriveIds.length > 0) { + payload.driveIds = selectedDriveIds; + } + + const token = await post('/api/auth/mcp-tokens', payload); + + // Add drive scopes to the token object for display + const tokenWithScopes: MCPToken = { + ...token, + driveScopes: selectedDriveIds.map(id => { + const drive = drives.find(d => d.id === id); + return { id, name: drive?.name || 'Unknown' }; + }), + }; setNewToken(token); - setTokens(prev => [token, ...prev]); + setTokens(prev => [tokenWithScopes, ...prev]); // Store the actual token value for the newly created token if (token.token) { setTokenMap(prev => new Map(prev).set(token.id, token.token)); setSelectedToken(token.token); } setNewTokenName(''); + setSelectedDriveIds([]); setCreateDialogOpen(false); setShowNewToken(true); toast.success('MCP token created successfully'); @@ -99,6 +142,14 @@ export default function MCPSettingsView() { } }; + const toggleDriveSelection = (driveId: string) => { + setSelectedDriveIds(prev => + prev.includes(driveId) + ? prev.filter(id => id !== driveId) + : [...prev, driveId] + ); + }; + const deleteToken = async (tokenId: string) => { try { await del(`/api/auth/mcp-tokens/${tokenId}`); @@ -231,18 +282,24 @@ export default function MCPSettingsView() {

Your Tokens

- + { + setCreateDialogOpen(open); + if (!open) { + setNewTokenName(''); + setSelectedDriveIds([]); + } + }}> - + Create New MCP Token - Give your token a descriptive name to help you identify it later. + Give your token a descriptive name and optionally restrict it to specific drives.
@@ -261,6 +318,49 @@ export default function MCPSettingsView() { }} />
+ +
+
+ + +
+

+ {selectedDriveIds.length === 0 + ? 'This token will have access to all your drives.' + : `This token will only have access to ${selectedDriveIds.length} selected drive${selectedDriveIds.length === 1 ? '' : 's'}.`} +

+ + {drives.length > 0 && ( +
+ {drives.map((drive) => ( +
+ toggleDriveSelection(drive.id)} + /> + +
+ ))} +
+ )} + + {selectedDriveIds.length > 0 && ( + + )} +