|
1 | 1 | import { db } from '@sim/db' |
2 | 2 | import { document, knowledgeBase, workspaceFile } from '@sim/db/schema' |
3 | 3 | import { createLogger } from '@sim/logger' |
4 | | -import { and, eq, isNull, like, or } from 'drizzle-orm' |
| 4 | +import { and, eq, isNull } from 'drizzle-orm' |
5 | 5 | import { NextResponse } from 'next/server' |
6 | 6 | import { getFileMetadata } from '@/lib/uploads' |
7 | 7 | import type { StorageContext } from '@/lib/uploads/config' |
@@ -371,73 +371,130 @@ async function verifyCopilotFileAccess( |
371 | 371 | } |
372 | 372 |
|
373 | 373 | /** |
374 | | - * Verify access to KB files |
375 | | - * KB files: kb/filename |
| 374 | + * Whether an active KB document (non-archived/excluded/deleted, in a |
| 375 | + * non-deleted KB) in the owning workspace references exactly `cloudKey`, matched |
| 376 | + * on the document's persisted canonical `storageKey`. This is an exact, indexed |
| 377 | + * lookup — no URL parsing or wildcard matching at read time. It is a lifecycle |
| 378 | + * signal only: it reflects whether the file is still part of a live KB, not who |
| 379 | + * owns it (ownership comes from the binding). |
| 380 | + */ |
| 381 | +async function hasActiveKbDocumentForKey(cloudKey: string, workspaceId: string): Promise<boolean> { |
| 382 | + const rows = await db |
| 383 | + .select({ id: document.id }) |
| 384 | + .from(document) |
| 385 | + .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) |
| 386 | + .where( |
| 387 | + and( |
| 388 | + eq(knowledgeBase.workspaceId, workspaceId), |
| 389 | + eq(document.storageKey, cloudKey), |
| 390 | + eq(document.userExcluded, false), |
| 391 | + isNull(document.archivedAt), |
| 392 | + isNull(document.deletedAt), |
| 393 | + isNull(knowledgeBase.deletedAt) |
| 394 | + ) |
| 395 | + ) |
| 396 | + .limit(1) |
| 397 | + |
| 398 | + return rows.length > 0 |
| 399 | +} |
| 400 | + |
| 401 | +/** |
| 402 | + * Verify access to KB files (`kb/<key>`). |
| 403 | + * |
| 404 | + * Authorization is determined entirely by clear state: |
| 405 | + * 1. Ownership — the trusted `workspace_files` binding (exact key) names the |
| 406 | + * owning workspace; the caller must have permission on it. Ownership is |
| 407 | + * never inferred from an attacker-authorable `document.fileUrl`. |
| 408 | + * 2. Liveness — an active document must still reference the exact key, so the |
| 409 | + * retained bytes of an archived document or soft-deleted KB are not |
| 410 | + * downloadable (the liveness document is not an authorization signal). |
| 411 | + * |
| 412 | + * A missing binding denies (the ownership backfill populates bindings for |
| 413 | + * pre-existing objects before this path is deployed). |
376 | 414 | */ |
377 | 415 | async function verifyKBFileAccess( |
378 | 416 | cloudKey: string, |
379 | 417 | userId: string, |
380 | 418 | customConfig?: StorageConfig |
381 | 419 | ): Promise<boolean> { |
382 | 420 | try { |
383 | | - const activeKbFileDocuments = await db |
384 | | - .select({ |
385 | | - workspaceId: knowledgeBase.workspaceId, |
386 | | - }) |
387 | | - .from(document) |
388 | | - .innerJoin(knowledgeBase, eq(document.knowledgeBaseId, knowledgeBase.id)) |
389 | | - .where( |
390 | | - and( |
391 | | - eq(document.userExcluded, false), |
392 | | - isNull(document.archivedAt), |
393 | | - isNull(document.deletedAt), |
394 | | - isNull(knowledgeBase.deletedAt), |
395 | | - or( |
396 | | - like(document.fileUrl, `%${cloudKey}%`), |
397 | | - like(document.fileUrl, `%${encodeURIComponent(cloudKey)}%`) |
398 | | - ) |
399 | | - ) |
400 | | - ) |
401 | | - .limit(10) |
402 | | - |
403 | | - for (const doc of activeKbFileDocuments) { |
404 | | - if (!doc.workspaceId) { |
405 | | - continue |
406 | | - } |
| 421 | + const binding = await getFileMetadataByKey(cloudKey, 'knowledge-base', { |
| 422 | + includeDeleted: true, |
| 423 | + }) |
407 | 424 |
|
408 | | - const permission = await getUserEntityPermissions(userId, 'workspace', doc.workspaceId) |
409 | | - if (permission !== null) { |
410 | | - logger.debug('KB file access granted (active document lookup)', { |
411 | | - userId, |
412 | | - workspaceId: doc.workspaceId, |
413 | | - cloudKey, |
414 | | - }) |
415 | | - return true |
416 | | - } |
| 425 | + if (!binding) { |
| 426 | + logger.warn('KB file access denied: no ownership binding', { userId, cloudKey }) |
| 427 | + return false |
| 428 | + } |
| 429 | + if (binding.deletedAt) { |
| 430 | + logger.warn('KB file access denied for deleted file binding', { userId, cloudKey }) |
| 431 | + return false |
| 432 | + } |
| 433 | + if (!binding.workspaceId) { |
| 434 | + logger.warn('KB file binding missing workspace owner', { userId, cloudKey }) |
| 435 | + return false |
417 | 436 | } |
418 | 437 |
|
419 | | - // KB file access must resolve through an active KB document. Metadata alone is not enough |
420 | | - // because parent archives intentionally keep the underlying file bytes around for history. |
421 | | - const fileRecord = await getFileMetadataByKey(cloudKey, 'knowledge-base', { |
422 | | - includeDeleted: true, |
423 | | - }) |
| 438 | + const permission = await getUserEntityPermissions(userId, 'workspace', binding.workspaceId) |
| 439 | + if (permission === null) { |
| 440 | + logger.warn('User does not have workspace access for KB file', { |
| 441 | + userId, |
| 442 | + workspaceId: binding.workspaceId, |
| 443 | + cloudKey, |
| 444 | + }) |
| 445 | + return false |
| 446 | + } |
424 | 447 |
|
425 | | - if (fileRecord?.deletedAt) { |
426 | | - logger.warn('KB file access denied for deleted file metadata', { userId, cloudKey }) |
| 448 | + if (!(await hasActiveKbDocumentForKey(cloudKey, binding.workspaceId))) { |
| 449 | + logger.warn('KB file access denied: no active document references the file', { |
| 450 | + userId, |
| 451 | + cloudKey, |
| 452 | + }) |
427 | 453 | return false |
428 | 454 | } |
429 | 455 |
|
430 | | - logger.warn('KB file access denied because no active KB document matched the file', { |
431 | | - cloudKey, |
| 456 | + logger.debug('KB file access granted (ownership binding)', { |
432 | 457 | userId, |
| 458 | + workspaceId: binding.workspaceId, |
| 459 | + cloudKey, |
433 | 460 | }) |
434 | | - return false |
| 461 | + return true |
435 | 462 | } catch (error) { |
436 | 463 | logger.error('Error verifying KB file access', { cloudKey, userId, error }) |
437 | 464 | return false |
438 | 465 | } |
439 | 466 | } |
440 | 467 |
|
| 468 | +/** |
| 469 | + * Authorize a destructive operation (delete) on a KB file. |
| 470 | + * |
| 471 | + * Binding-only: resolves the owning workspace from the trusted ownership binding |
| 472 | + * and requires write/admin permission. Never uses the transitional read fallback, |
| 473 | + * so a not-yet-bound key cannot be deleted cross-tenant. |
| 474 | + */ |
| 475 | +export async function verifyKBFileWriteAccess(cloudKey: string, userId: string): Promise<boolean> { |
| 476 | + try { |
| 477 | + const binding = await getFileMetadataByKey(cloudKey, 'knowledge-base') |
| 478 | + if (!binding?.workspaceId) { |
| 479 | + logger.warn('KB file delete denied: no ownership binding', { userId, cloudKey }) |
| 480 | + return false |
| 481 | + } |
| 482 | + const permission = await getUserEntityPermissions(userId, 'workspace', binding.workspaceId) |
| 483 | + if (permission !== 'write' && permission !== 'admin') { |
| 484 | + logger.warn('KB file delete denied: write/admin required on owner workspace', { |
| 485 | + userId, |
| 486 | + workspaceId: binding.workspaceId, |
| 487 | + cloudKey, |
| 488 | + }) |
| 489 | + return false |
| 490 | + } |
| 491 | + return true |
| 492 | + } catch (error) { |
| 493 | + logger.error('Error verifying KB file write access', { cloudKey, userId, error }) |
| 494 | + return false |
| 495 | + } |
| 496 | +} |
| 497 | + |
441 | 498 | /** |
442 | 499 | * Verify access to chat files |
443 | 500 | * Chat files: chat/filename |
|
0 commit comments