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..f6fff5333 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 @@ -18,6 +18,8 @@ vi.mock('@pagespace/db', () => ({ ]), }), }), + // Support for db.transaction() - executes callback with a mock tx object + transaction: vi.fn(), query: { mcpTokens: { findMany: vi.fn(), @@ -33,6 +35,7 @@ vi.mock('@pagespace/db', () => ({ }), }, mcpTokens: {}, + mcpTokenDrives: {}, eq: vi.fn((field, value) => ({ field, value })), and: vi.fn((...conditions) => conditions), })); @@ -64,10 +67,28 @@ vi.mock('@pagespace/lib/monitoring/activity-logger', () => ({ logTokenActivity: vi.fn(), })); +vi.mock('@pagespace/lib/services/drive-service', () => ({ + getDriveAccess: vi.fn().mockResolvedValue({ + isOwner: true, + isAdmin: true, + isMember: true, + role: 'OWNER', + }), + listAccessibleDrives: vi.fn().mockResolvedValue([]), +})); + import { db } from '@pagespace/db'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { logTokenActivity } from '@pagespace/lib/monitoring/activity-logger'; +// Helper to create a mock transaction that executes callback with a mock tx +const setupTransactionMock = (insertMock: ReturnType) => { + (db.transaction as unknown as Mock).mockImplementation(async (callback) => { + const tx = { insert: insertMock }; + return callback(tx); + }); +}; + describe('/api/auth/mcp-tokens', () => { beforeEach(() => { vi.clearAllMocks(); @@ -78,31 +99,45 @@ describe('/api/auth/mcp-tokens', () => { role: 'user', tokenVersion: 0, tokenType: 'session', - sessionId: 'test-session-id', - + sessionId: 'test-session-id', }); (isAuthError as unknown as Mock).mockReturnValue(false); + + // Default transaction mock that returns a basic token + const defaultInsertMock = vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([ + { + id: 'new-mcp-token-id', + name: 'Test Token', + createdAt: new Date(), + }, + ]), + }), + }); + setupTransactionMock(defaultInsertMock); }); describe('POST /api/auth/mcp-tokens', () => { describe('successful token creation', () => { it('returns 200 with new token data', async () => { - // Arrange - capture the values passed to insert + // Arrange - capture the values passed to insert via transaction let capturedValues: Record | undefined; - const mockValues = vi.fn().mockImplementation((vals) => { - capturedValues = vals; - return { - returning: vi.fn().mockResolvedValue([ - { - id: 'new-mcp-token-id', - name: vals.name, - token: vals.token, - createdAt: new Date(), - }, - ]), - }; + const mockInsert = vi.fn().mockReturnValue({ + values: vi.fn().mockImplementation((vals) => { + capturedValues = vals; + return { + returning: vi.fn().mockResolvedValue([ + { + id: 'new-mcp-token-id', + name: vals.name, + createdAt: new Date(), + }, + ]), + }; + }), }); - (db.insert as unknown as Mock).mockReturnValue({ values: mockValues }); + setupTransactionMock(mockInsert); const request = new NextRequest('http://localhost/api/auth/mcp-tokens', { method: 'POST', @@ -146,15 +181,15 @@ describe('/api/auth/mcp-tokens', () => { }); it('generates token with mcp_ prefix', async () => { - // Arrange - mock returns token with mcp_ prefix - const mockValues = vi.fn().mockImplementation((vals) => { - return { + // Arrange - mock transaction returns token + const mockInsert = vi.fn().mockReturnValue({ + values: vi.fn().mockImplementation((vals) => ({ returning: vi.fn().mockResolvedValue([ - { id: 'id', name: vals.name, token: vals.token, createdAt: new Date() }, + { id: 'id', name: vals.name, createdAt: new Date() }, ]), - }; + })), }); - (db.insert as Mock).mockReturnValue({ values: mockValues }); + setupTransactionMock(mockInsert); const request = new NextRequest('http://localhost/api/auth/mcp-tokens', { method: 'POST', @@ -171,23 +206,25 @@ describe('/api/auth/mcp-tokens', () => { const body = await response.json(); // Assert - verify RESPONSE token has mcp_ prefix (DB stores hash, response returns raw token) - expect(db.insert).toHaveBeenCalled(); + expect(db.transaction).toHaveBeenCalled(); expect(body.token).toBeDefined(); expect(body.token).toMatch(/^mcp_/); }); it('associates token with authenticated user', async () => { - // Arrange - capture userId + // Arrange - capture userId via transaction mock let capturedUserId: string | undefined; - const mockValues = vi.fn().mockImplementation((vals) => { - capturedUserId = vals.userId; - return { - returning: vi.fn().mockResolvedValue([ - { id: 'id', name: vals.name, token: vals.token, createdAt: new Date() }, - ]), - }; + const mockInsert = vi.fn().mockReturnValue({ + values: vi.fn().mockImplementation((vals) => { + capturedUserId = vals.userId; + return { + returning: vi.fn().mockResolvedValue([ + { id: 'id', name: vals.name, createdAt: new Date() }, + ]), + }; + }), }); - (db.insert as Mock).mockReturnValue({ values: mockValues }); + setupTransactionMock(mockInsert); const request = new NextRequest('http://localhost/api/auth/mcp-tokens', { method: 'POST', @@ -203,7 +240,7 @@ describe('/api/auth/mcp-tokens', () => { await POST(request); // Assert - verify token is associated with authenticated user - expect(db.insert).toHaveBeenCalled(); + expect(db.transaction).toHaveBeenCalled(); expect(capturedUserId).toBe('test-user-id'); }); }); @@ -332,12 +369,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..b967f4720 100644 --- a/apps/web/src/app/api/auth/mcp-tokens/route.ts +++ b/apps/web/src/app/api/auth/mcp-tokens/route.ts @@ -1,10 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; -import { db, mcpTokens } from '@pagespace/db'; +import { db, mcpTokens, mcpTokenDrives, drives, inArray } from '@pagespace/db'; import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; import { z } from 'zod/v4'; import { loggers } from '@pagespace/lib/server'; import { getActorInfo, logTokenActivity } from '@pagespace/lib/monitoring/activity-logger'; import { generateToken } from '@pagespace/lib/auth'; +import { getDriveAccess } from '@pagespace/lib/services/drive-service'; const AUTH_OPTIONS_READ = { allow: ['session'] as const, requireCSRF: false }; const AUTH_OPTIONS_WRITE = { allow: ['session'] as const, requireCSRF: true }; @@ -12,6 +13,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,19 +26,74 @@ export async function POST(req: NextRequest) { try { const body = await req.json(); - const { name } = createTokenSchema.parse(body); + const { name, driveIds: rawDriveIds } = createTokenSchema.parse(body); + + // Deduplicate drive IDs to prevent unique constraint violations + const driveIds = rawDriveIds ? [...new Set(rawDriveIds)] : []; + + // Zero Trust: Validate that the user has access to each specified drive + // Users can scope tokens to any drive they have access to (owned OR member) + if (driveIds.length > 0) { + const invalidDriveIds: string[] = []; + + for (const driveId of driveIds) { + const access = await getDriveAccess(driveId, userId); + // User must be owner, admin, or member to scope a token to this drive + if (!access.isOwner && !access.isMember) { + invalidDriveIds.push(driveId); + } + } + + if (invalidDriveIds.length > 0) { + return NextResponse.json( + { error: 'You do not have access to these drives: ' + invalidDriveIds.join(', ') }, + { status: 403 } + ); + } + } // 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 const { token: rawToken, hash: tokenHash, tokenPrefix } = generateToken('mcp'); - // Store ONLY the hash in the database - const [newToken] = await db.insert(mcpTokens).values({ - userId, - tokenHash, - tokenPrefix, - name, - }).returning(); + // Determine if this token is scoped (fail-closed security) + const isScoped = !!(driveIds && driveIds.length > 0); + + // Use transaction to ensure token and drive scopes are created atomically + // If drive scope insertion fails, the token should not exist + const newToken = await db.transaction(async (tx) => { + // Store ONLY the hash in the database + // isScoped=true means if all scoped drives are deleted, deny access (not grant all) + const [token] = await tx.insert(mcpTokens).values({ + userId, + tokenHash, + tokenPrefix, + name, + isScoped, + }).returning(); + + // If drive scopes are specified, create the junction table entries + if (driveIds.length > 0) { + await tx.insert(mcpTokenDrives).values( + driveIds.map(driveId => ({ + tokenId: token.id, + driveId, + })) + ); + } + + return token; + }); + + // Fetch drive names for consistent response format with GET + let driveScopes: { id: string; name: string }[] = []; + if (driveIds.length > 0) { + const driveRecords = await db.query.drives.findMany({ + where: inArray(drives.id, driveIds), + columns: { id: true, name: true }, + }); + driveScopes = driveRecords.map(d => ({ id: d.id, name: d.name })); + } // Log activity for audit trail (token creation is a security event) const actorInfo = await getActorInfo(userId); @@ -45,11 +104,14 @@ export async function POST(req: NextRequest) { }, actorInfo); // Return the raw token ONCE to the user - this is the only time they'll see it + // Response format matches GET for consistency return NextResponse.json({ id: newToken.id, name: newToken.name, token: rawToken, // Return the actual token, not the hash createdAt: newToken.createdAt, + lastUsed: null, // New token hasn't been used yet + driveScopes, // Consistent format with GET: { id, name }[] }); } catch (error) { loggers.auth.error('Error creating MCP token:', error as Error); @@ -67,7 +129,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), @@ -78,10 +140,42 @@ export async function GET(req: NextRequest) { name: true, lastUsed: true, createdAt: true, + isScoped: true, + }, + with: { + driveScopes: { + columns: { + driveId: true, + }, + with: { + drive: { + columns: { + id: true, + name: true, + }, + }, + }, + }, }, }); - return NextResponse.json(tokens); + // Transform the response to include drive info + // Filter out any scopes where the drive may have been deleted + const tokensWithDrives = tokens.map(token => ({ + id: token.id, + name: token.name, + lastUsed: token.lastUsed, + createdAt: token.createdAt, + isScoped: token.isScoped, + driveScopes: token.driveScopes + .filter(scope => scope.drive != null) + .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/drives/[driveId]/__tests__/route.test.ts b/apps/web/src/app/api/drives/[driveId]/__tests__/route.test.ts index f136b5421..a1498929d 100644 --- a/apps/web/src/app/api/drives/[driveId]/__tests__/route.test.ts +++ b/apps/web/src/app/api/drives/[driveId]/__tests__/route.test.ts @@ -37,6 +37,13 @@ vi.mock('@/lib/websocket', () => ({ vi.mock('@/lib/auth', () => ({ authenticateRequestWithOptions: vi.fn(), isAuthError: vi.fn(), + // MCP scope check - returns null (allowed) by default for session auth tests + checkMCPDriveScope: vi.fn().mockReturnValue(null), +})); + +vi.mock('@pagespace/lib/monitoring/activity-logger', () => ({ + getActorInfo: vi.fn().mockResolvedValue({ actorEmail: 'test@example.com', actorDisplayName: 'Test User' }), + logDriveActivity: vi.fn(), })); import { diff --git a/apps/web/src/app/api/drives/[driveId]/agents/route.ts b/apps/web/src/app/api/drives/[driveId]/agents/route.ts index 3abd5c372..de69204bd 100644 --- a/apps/web/src/app/api/drives/[driveId]/agents/route.ts +++ b/apps/web/src/app/api/drives/[driveId]/agents/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { authenticateRequestWithOptions, isAuthError, checkMCPDriveScope } from '@/lib/auth'; import { db, pages, drives, eq, and } from '@pagespace/db'; import { getUserDriveAccess, canUserViewPage } from '@pagespace/lib/server'; import { loggers } from '@pagespace/lib/server'; @@ -35,6 +35,11 @@ export async function GET( const { userId } = auth; const { driveId } = await context.params; + + // Check MCP token scope before drive access + const scopeError = checkMCPDriveScope(auth, driveId); + if (scopeError) return scopeError; + const { searchParams } = new URL(request.url); const includeSystemPrompt = searchParams.get('includeSystemPrompt') === 'true'; diff --git a/apps/web/src/app/api/drives/[driveId]/pages/route.ts b/apps/web/src/app/api/drives/[driveId]/pages/route.ts index e88ca2dc0..557974570 100644 --- a/apps/web/src/app/api/drives/[driveId]/pages/route.ts +++ b/apps/web/src/app/api/drives/[driveId]/pages/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { buildTree } from '@pagespace/lib/server'; import { pages, drives, pagePermissions, driveMembers, taskItems, userPageViews, db, and, eq, inArray, asc, sql, isNotNull } from '@pagespace/db'; import { loggers } from '@pagespace/lib/server'; -import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { authenticateRequestWithOptions, isAuthError, checkMCPDriveScope } from '@/lib/auth'; import { jsonResponse } from '@pagespace/lib/api-utils'; const AUTH_OPTIONS = { allow: ['session', 'mcp'] as const, requireCSRF: false }; @@ -69,11 +69,16 @@ export async function GET( if (isAuthError(auth)) { return auth.error; } + + const { driveId } = await context.params; + + // Check MCP token scope before drive access + const scopeError = checkMCPDriveScope(auth, driveId); + if (scopeError) return scopeError; + const userId = auth.userId; try { - const { driveId } = await context.params; - // Find drive by id, but don't scope to owner yet const drive = await db.query.drives.findFirst({ where: eq(drives.id, driveId), diff --git a/apps/web/src/app/api/drives/[driveId]/route.ts b/apps/web/src/app/api/drives/[driveId]/route.ts index 04c4b4c10..3522e2084 100644 --- a/apps/web/src/app/api/drives/[driveId]/route.ts +++ b/apps/web/src/app/api/drives/[driveId]/route.ts @@ -9,7 +9,7 @@ import { } from '@pagespace/lib/server'; import { loggers } from '@pagespace/lib/server'; import { broadcastDriveEvent, createDriveEventPayload } from '@/lib/websocket'; -import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { authenticateRequestWithOptions, isAuthError, checkMCPDriveScope } from '@/lib/auth'; import { getActorInfo, logDriveActivity } from '@pagespace/lib/monitoring/activity-logger'; const AUTH_OPTIONS_READ = { allow: ['session', 'mcp'] as const, requireCSRF: false }; @@ -36,6 +36,11 @@ export async function GET( if (isAuthError(auth)) { return auth.error; } + + // Check MCP token scope before drive access + const scopeError = checkMCPDriveScope(auth, driveId); + if (scopeError) return scopeError; + const userId = auth.userId; const driveWithAccess = await getDriveWithAccess(driveId, userId); @@ -70,6 +75,11 @@ export async function PATCH( if (isAuthError(auth)) { return auth.error; } + + // Check MCP token scope before drive access + const scopeError = checkMCPDriveScope(auth, driveId); + if (scopeError) return scopeError; + const userId = auth.userId; const body = await request.json(); @@ -163,6 +173,11 @@ export async function DELETE( if (isAuthError(auth)) { return auth.error; } + + // Check MCP token scope before drive access + const scopeError = checkMCPDriveScope(auth, driveId); + if (scopeError) return scopeError; + const userId = auth.userId; // Check drive exists diff --git a/apps/web/src/app/api/drives/[driveId]/search/glob/__tests__/route.test.ts b/apps/web/src/app/api/drives/[driveId]/search/glob/__tests__/route.test.ts index 2a53785d8..9f43fa480 100644 --- a/apps/web/src/app/api/drives/[driveId]/search/glob/__tests__/route.test.ts +++ b/apps/web/src/app/api/drives/[driveId]/search/glob/__tests__/route.test.ts @@ -28,6 +28,7 @@ vi.mock('@pagespace/lib/server', () => ({ vi.mock('@/lib/auth', () => ({ authenticateRequestWithOptions: vi.fn(), isAuthError: vi.fn(), + checkMCPDriveScope: vi.fn(() => null), // Allow all drives by default })); import { checkDriveAccessForSearch, globSearchPages } from '@pagespace/lib/server'; diff --git a/apps/web/src/app/api/drives/[driveId]/search/glob/route.ts b/apps/web/src/app/api/drives/[driveId]/search/glob/route.ts index 3ba7f53f1..ffdf3cbc0 100644 --- a/apps/web/src/app/api/drives/[driveId]/search/glob/route.ts +++ b/apps/web/src/app/api/drives/[driveId]/search/glob/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { authenticateRequestWithOptions, isAuthError, checkMCPDriveScope } from '@/lib/auth'; import { checkDriveAccessForSearch, globSearchPages, @@ -25,6 +25,11 @@ export async function GET( const { userId } = auth; const { driveId } = await context.params; + + // Check MCP token scope before drive access + const scopeError = checkMCPDriveScope(auth, driveId); + if (scopeError) return scopeError; + const { searchParams } = new URL(request.url); const pattern = searchParams.get('pattern'); const includeTypesParam = searchParams.get('includeTypes'); diff --git a/apps/web/src/app/api/drives/[driveId]/search/regex/__tests__/route.test.ts b/apps/web/src/app/api/drives/[driveId]/search/regex/__tests__/route.test.ts index 9d97013ea..4269c8122 100644 --- a/apps/web/src/app/api/drives/[driveId]/search/regex/__tests__/route.test.ts +++ b/apps/web/src/app/api/drives/[driveId]/search/regex/__tests__/route.test.ts @@ -28,6 +28,7 @@ vi.mock('@pagespace/lib/server', () => ({ vi.mock('@/lib/auth', () => ({ authenticateRequestWithOptions: vi.fn(), isAuthError: vi.fn(), + checkMCPDriveScope: vi.fn(() => null), // Allow all drives by default })); import { checkDriveAccessForSearch, regexSearchPages } from '@pagespace/lib/server'; diff --git a/apps/web/src/app/api/drives/[driveId]/search/regex/route.ts b/apps/web/src/app/api/drives/[driveId]/search/regex/route.ts index e67be508b..2b9c36122 100644 --- a/apps/web/src/app/api/drives/[driveId]/search/regex/route.ts +++ b/apps/web/src/app/api/drives/[driveId]/search/regex/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from 'next/server'; -import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { authenticateRequestWithOptions, isAuthError, checkMCPDriveScope } from '@/lib/auth'; import { checkDriveAccessForSearch, regexSearchPages, @@ -22,6 +22,11 @@ export async function GET( const { userId } = auth; const { driveId } = await context.params; + + // Check MCP token scope before drive access + const scopeError = checkMCPDriveScope(auth, driveId); + if (scopeError) return scopeError; + const { searchParams } = new URL(request.url); const pattern = searchParams.get('pattern'); const searchIn = (searchParams.get('searchIn') || 'content') as 'content' | 'title' | 'both'; diff --git a/apps/web/src/app/api/drives/__tests__/route.test.ts b/apps/web/src/app/api/drives/__tests__/route.test.ts index 258f8b47d..fe7639321 100644 --- a/apps/web/src/app/api/drives/__tests__/route.test.ts +++ b/apps/web/src/app/api/drives/__tests__/route.test.ts @@ -44,6 +44,8 @@ vi.mock('@/lib/websocket', () => ({ vi.mock('@/lib/auth', () => ({ authenticateRequestWithOptions: vi.fn(), isAuthError: vi.fn(), + filterDrivesByMCPScope: vi.fn((auth, driveIds) => driveIds), // Pass through all drive IDs by default + checkMCPCreateScope: vi.fn(() => null), // Allow all creates by default })); import { listAccessibleDrives, createDrive, loggers } from '@pagespace/lib/server'; @@ -125,14 +127,21 @@ describe('GET /api/drives', () => { const request = new Request('https://example.com/api/drives'); await GET(request); - expect(listAccessibleDrives).toHaveBeenCalledWith(mockUserId, { includeTrash: false }); + expect(listAccessibleDrives).toHaveBeenCalledWith(mockUserId, { includeTrash: false, tokenScopable: false }); }); it('should pass includeTrash=true when query param is set', async () => { const request = new Request('https://example.com/api/drives?includeTrash=true'); await GET(request); - expect(listAccessibleDrives).toHaveBeenCalledWith(mockUserId, { includeTrash: true }); + expect(listAccessibleDrives).toHaveBeenCalledWith(mockUserId, { includeTrash: true, tokenScopable: false }); + }); + + it('should pass tokenScopable=true when query param is set', async () => { + const request = new Request('https://example.com/api/drives?tokenScopable=true'); + await GET(request); + + expect(listAccessibleDrives).toHaveBeenCalledWith(mockUserId, { includeTrash: false, tokenScopable: true }); }); }); diff --git a/apps/web/src/app/api/drives/route.ts b/apps/web/src/app/api/drives/route.ts index 6dbc524bc..bca32b396 100644 --- a/apps/web/src/app/api/drives/route.ts +++ b/apps/web/src/app/api/drives/route.ts @@ -3,7 +3,7 @@ import { listAccessibleDrives, createDrive } from '@pagespace/lib/server'; import { broadcastDriveEvent, createDriveEventPayload } from '@/lib/websocket'; import { loggers } from '@pagespace/lib/server'; import { trackDriveOperation } from '@pagespace/lib/activity-tracker'; -import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { authenticateRequestWithOptions, isAuthError, filterDrivesByMCPScope, checkMCPCreateScope } from '@/lib/auth'; import { jsonResponse } from '@pagespace/lib/api-utils'; import { getActorInfo, logDriveActivity } from '@pagespace/lib/monitoring/activity-logger'; @@ -21,9 +21,15 @@ export async function GET(req: Request) { const url = new URL(req.url); const includeTrash = url.searchParams.get('includeTrash') === 'true'; + const tokenScopable = url.searchParams.get('tokenScopable') === 'true'; try { - const drives = await listAccessibleDrives(userId, { includeTrash }); + const allDrives = await listAccessibleDrives(userId, { includeTrash, tokenScopable }); + + // Filter drives by MCP token scope (no-op for session auth or unscoped tokens) + const allowedDriveIds = filterDrivesByMCPScope(auth, allDrives.map(d => d.id)); + const allowedSet = new Set(allowedDriveIds); + const drives = allDrives.filter(d => allowedSet.has(d.id)); loggers.api.debug('[DEBUG] Drives API - Found drives:', { count: drives.length, @@ -43,6 +49,12 @@ export async function POST(request: Request) { return auth.error; } + // Scoped MCP tokens cannot create new drives + const scopeError = checkMCPCreateScope(auth, null); + if (scopeError) { + return scopeError; + } + const userId = auth.userId; try { diff --git a/apps/web/src/app/api/mcp/documents/__tests__/route.security.test.ts b/apps/web/src/app/api/mcp/documents/__tests__/route.security.test.ts index a5bd9a37b..ca1293c8b 100644 --- a/apps/web/src/app/api/mcp/documents/__tests__/route.security.test.ts +++ b/apps/web/src/app/api/mcp/documents/__tests__/route.security.test.ts @@ -25,6 +25,7 @@ const mockGetActorInfo = vi.fn(); vi.mock('@/lib/auth', () => ({ authenticateMCPRequest: (...args: unknown[]) => mockAuthenticateMCPRequest(...args), isAuthError: (result: unknown) => 'error' in (result as object), + isMCPAuthResult: (result: unknown) => !('error' in (result as object)) && (result as { tokenType?: string }).tokenType === 'mcp', })); vi.mock('@pagespace/lib/server', () => ({ @@ -105,6 +106,7 @@ describe('MCP Documents API - Security Tests', () => { role: 'user', tokenVersion: 1, adminRoleVersion: 0, + allowedDriveIds: [], // Empty array means no drive restrictions }); // Default actor info @@ -335,6 +337,7 @@ describe('MCP Documents API - Security Tests', () => { role: 'user', tokenVersion: 1, adminRoleVersion: 0, + allowedDriveIds: [], // Empty array means no drive restrictions }); mockGetUserAccessLevel.mockResolvedValue({ diff --git a/apps/web/src/app/api/mcp/documents/route.ts b/apps/web/src/app/api/mcp/documents/route.ts index 17cf57a74..ba7a4614e 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..749b314b0 100644 --- a/apps/web/src/app/api/mcp/drives/route.ts +++ b/apps/web/src/app/api/mcp/drives/route.ts @@ -1,11 +1,12 @@ import { NextRequest, NextResponse } from 'next/server'; -import { db, drives, eq } from '@pagespace/db'; +import { db, drives } 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'; +import { listAccessibleDrives } from '@pagespace/lib/services/drive-service'; // Schema for drive creation const createDriveSchema = z.object({ @@ -18,6 +19,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) > 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(); @@ -66,7 +76,8 @@ export async function POST(req: NextRequest) { } } -// GET endpoint to list drives (for completeness) +// GET endpoint to list drives +// Zero Trust: Returns all drives user has access to (owned + shared), filtered by token scope export async function GET(req: NextRequest) { const auth = await authenticateMCPRequest(req); if (isAuthError(auth)) { @@ -75,12 +86,28 @@ 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), - }); - return NextResponse.json(userDrives); + // Check if this MCP token has drive scope restrictions + let allowedDriveIds: string[] = []; + if (isMCPAuthResult(auth)) { + allowedDriveIds = auth.allowedDriveIds ?? []; + } + + // Get all drives user has access to (owned + shared via membership) + const allAccessibleDrives = await listAccessibleDrives(userId); + + // Filter by token scope if applicable + let filteredDrives; + if (allowedDriveIds.length > 0) { + // Token is scoped to specific drives - only return those the user can access + const scopeSet = new Set(allowedDriveIds); + filteredDrives = allAccessibleDrives.filter(drive => scopeSet.has(drive.id)); + } else { + // Token has no scope restrictions - return all accessible drives + filteredDrives = allAccessibleDrives; + } + + return NextResponse.json(filteredDrives); } catch (error) { loggers.api.error('Error fetching drives:', error as Error); return NextResponse.json( diff --git a/apps/web/src/app/api/pages/[pageId]/__tests__/route.test.ts b/apps/web/src/app/api/pages/[pageId]/__tests__/route.test.ts index 700ded718..4c4e01987 100644 --- a/apps/web/src/app/api/pages/[pageId]/__tests__/route.test.ts +++ b/apps/web/src/app/api/pages/[pageId]/__tests__/route.test.ts @@ -26,6 +26,8 @@ vi.mock('@/services/api', () => ({ vi.mock('@/lib/auth', () => ({ authenticateRequestWithOptions: vi.fn(), isAuthError: vi.fn((result) => 'error' in result), + // MCP scope check - returns null (allowed) by default for session auth tests + checkMCPPageScope: vi.fn().mockResolvedValue(null), })); vi.mock('@/lib/websocket', () => ({ diff --git a/apps/web/src/app/api/pages/[pageId]/route.ts b/apps/web/src/app/api/pages/[pageId]/route.ts index 421de01ee..2c6e75d83 100644 --- a/apps/web/src/app/api/pages/[pageId]/route.ts +++ b/apps/web/src/app/api/pages/[pageId]/route.ts @@ -3,7 +3,7 @@ import { z } from "zod/v4"; import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket'; import { loggers, agentAwarenessCache, pageTreeCache } from '@pagespace/lib/server'; import { trackPageOperation } from '@pagespace/lib/activity-tracker'; -import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { authenticateRequestWithOptions, isAuthError, checkMCPPageScope } from '@/lib/auth'; import { jsonResponse } from '@pagespace/lib/api-utils'; import { pageService } from '@/services/api'; @@ -16,6 +16,11 @@ export async function GET(req: Request, { params }: { params: Promise<{ pageId: if (isAuthError(auth)) { return auth.error; } + + // Check MCP token scope before page access + const scopeError = await checkMCPPageScope(auth, pageId); + if (scopeError) return scopeError; + const userId = auth.userId; try { @@ -49,6 +54,11 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ pageId if (isAuthError(auth)) { return auth.error; } + + // Check MCP token scope before page access + const scopeError = await checkMCPPageScope(auth, pageId); + if (scopeError) return scopeError; + const userId = auth.userId; try { @@ -135,6 +145,11 @@ export async function DELETE(req: Request, { params }: { params: Promise<{ pageI if (isAuthError(auth)) { return auth.error; } + + // Check MCP token scope before page access + const scopeError = await checkMCPPageScope(auth, pageId); + if (scopeError) return scopeError; + const userId = auth.userId; try { diff --git a/apps/web/src/app/api/pages/__tests__/route.test.ts b/apps/web/src/app/api/pages/__tests__/route.test.ts index dd129432a..f861b71d8 100644 --- a/apps/web/src/app/api/pages/__tests__/route.test.ts +++ b/apps/web/src/app/api/pages/__tests__/route.test.ts @@ -24,6 +24,7 @@ vi.mock('@/services/api', () => ({ vi.mock('@/lib/auth', () => ({ authenticateRequestWithOptions: vi.fn(), isAuthError: vi.fn((result) => 'error' in result), + checkMCPCreateScope: vi.fn(() => null), // Allow all creates by default })); vi.mock('@/lib/websocket', () => ({ diff --git a/apps/web/src/app/api/pages/route.ts b/apps/web/src/app/api/pages/route.ts index d721b87a2..b4ae0f84a 100644 --- a/apps/web/src/app/api/pages/route.ts +++ b/apps/web/src/app/api/pages/route.ts @@ -3,7 +3,7 @@ import { z } from 'zod/v4'; import { broadcastPageEvent, createPageEventPayload } from '@/lib/websocket'; import { loggers, agentAwarenessCache, pageTreeCache } from '@pagespace/lib/server'; import { trackPageOperation } from '@pagespace/lib/activity-tracker'; -import { authenticateRequestWithOptions, isAuthError } from '@/lib/auth'; +import { authenticateRequestWithOptions, isAuthError, checkMCPCreateScope } from '@/lib/auth'; import { pageService } from '@/services/api'; const AUTH_OPTIONS = { allow: ['session', 'mcp'] as const, requireCSRF: true }; @@ -43,6 +43,12 @@ export async function POST(request: Request) { const validatedData = parseResult.data; + // Check MCP token scope - scoped tokens can only create pages in allowed drives + const scopeError = checkMCPCreateScope(auth, validatedData.driveId); + if (scopeError) { + return scopeError; + } + const result = await pageService.createPage(userId, { title: validatedData.title, type: validatedData.type, 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..f58dcc222 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,45 @@ 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; + isScoped: boolean; + 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 +67,7 @@ export default function MCPSettingsView() { useEffect(() => { loadTokens(); + loadDrives(); }, []); const loadTokens = async () => { @@ -70,6 +87,19 @@ export default function MCPSettingsView() { } }; + const loadDrives = async () => { + try { + // Only fetch drives that can be scoped to tokens (owned + member, not page-permission-only) + const response = await fetchWithAuth('/api/drives?tokenScopable=true'); + 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 +108,32 @@ 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, + isScoped: selectedDriveIds.length > 0, + 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 +145,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 +285,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 +321,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 && ( + + )} +