Files
simstudioai--sim/apps/sim/app/api/files/authorization.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

213 lines
8.5 KiB
TypeScript

/**
* Tests for KB file authorization (`verifyKBFileAccess` via `verifyFileAccess`).
*
* These lock in the security-critical contract: access is granted only when a
* trusted ownership binding names a workspace the caller can access AND an active
* document still references the exact key. A planted `document.fileUrl` (the
* reported vulnerability) can never grant access because ownership comes from the
* binding, not the document.
*
* @vitest-environment node
*/
import { dbChainMock, dbChainMockFns } from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetFileMetadataByKey, mockGetUserEntityPermissions, mockGetFileMetadata } = vi.hoisted(
() => ({
mockGetFileMetadataByKey: vi.fn(),
mockGetUserEntityPermissions: vi.fn(),
mockGetFileMetadata: vi.fn(),
})
)
vi.mock('@sim/db', () => dbChainMock)
vi.mock('@/lib/uploads', () => ({
getFileMetadata: mockGetFileMetadata,
}))
vi.mock('@/lib/uploads/config', () => ({
BLOB_CHAT_CONFIG: {},
S3_CHAT_CONFIG: {},
}))
vi.mock('@/lib/uploads/server/metadata', () => ({
getFileMetadataByKey: mockGetFileMetadataByKey,
}))
vi.mock('@/lib/uploads/utils/file-utils', () => ({
inferContextFromKey: vi.fn(() => 'knowledge-base'),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => ({
getUserEntityPermissions: mockGetUserEntityPermissions,
}))
vi.mock('@/executor/constants', () => ({
isUuid: vi.fn(() => false),
}))
import { verifyFileAccess, verifyKBFileWriteAccess } from '@/app/api/files/authorization'
const CLOUD_KEY = 'kb/1780162789495-secret.txt'
const USER_ID = 'user-1'
function grantAccess(cloudKey: string) {
return verifyFileAccess(cloudKey, USER_ID, undefined, 'knowledge-base')
}
describe('verifyKBFileAccess (binding-only)', () => {
beforeEach(() => {
vi.clearAllMocks()
// Default liveness query result: one active document references the exact storage key.
dbChainMockFns.limit.mockResolvedValue([{ id: 'doc-1' }])
})
it('grants access when the binding owner workspace is accessible and an active document references the key', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null })
mockGetUserEntityPermissions.mockResolvedValue('read')
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(true)
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER_ID, 'workspace', 'ws-1')
})
it('denies when the caller lacks permission on the owner workspace (cross-tenant)', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'victim-ws', deletedAt: null })
mockGetUserEntityPermissions.mockResolvedValue(null)
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false)
})
it('denies when there is no ownership binding (planted or un-backfilled key)', async () => {
mockGetFileMetadataByKey.mockResolvedValue(null)
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false)
// Authorization never consults workspace permissions without a binding.
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})
it('denies when the binding is soft-deleted', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: new Date() })
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false)
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})
it('denies when the binding has no workspace owner', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: null, deletedAt: null })
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false)
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})
it('denies when no active document references the key (archived/soft-deleted KB liveness)', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null })
mockGetUserEntityPermissions.mockResolvedValue('admin')
dbChainMockFns.limit.mockResolvedValue([])
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false)
})
it('fails closed when the binding lookup throws', async () => {
mockGetFileMetadataByKey.mockRejectedValue(new Error('db down'))
await expect(grantAccess(CLOUD_KEY)).resolves.toBe(false)
})
})
describe('verifyKBFileWriteAccess (binding-only delete authorization)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('grants delete when the caller has write on the owner workspace', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null })
mockGetUserEntityPermissions.mockResolvedValue('write')
await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(true)
})
it('grants delete when the caller is admin on the owner workspace', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null })
mockGetUserEntityPermissions.mockResolvedValue('admin')
await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(true)
})
it('denies delete when the caller has only read on the owner workspace', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1', deletedAt: null })
mockGetUserEntityPermissions.mockResolvedValue('read')
await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false)
})
it('denies delete when there is no binding (no fallback)', async () => {
mockGetFileMetadataByKey.mockResolvedValue(null)
await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false)
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})
it('fails closed when the binding lookup throws', async () => {
mockGetFileMetadataByKey.mockRejectedValue(new Error('db down'))
await expect(verifyKBFileWriteAccess(CLOUD_KEY, USER_ID)).resolves.toBe(false)
})
})
describe('public-context access (profile-pictures / og-images / workspace-logos)', () => {
beforeEach(() => {
vi.clearAllMocks()
})
function read(cloudKey: string, context: 'profile-pictures' | 'og-images' | 'workspace-logos') {
return verifyFileAccess(cloudKey, USER_ID, undefined, context, false)
}
function write(cloudKey: string, context: 'profile-pictures' | 'og-images' | 'workspace-logos') {
return verifyFileAccess(cloudKey, USER_ID, undefined, context, false, { requireWrite: true })
}
it('grants public reads without any ownership check', async () => {
await expect(read('og-images/banner.png', 'og-images')).resolves.toBe(true)
await expect(read('profile-pictures/123-avatar.png', 'profile-pictures')).resolves.toBe(true)
await expect(read('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(true)
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
expect(mockGetFileMetadata).not.toHaveBeenCalled()
})
it('denies a cross-tenant delete that names a workspace key under a public context', async () => {
await expect(write('workspace/victim-ws/123-report.pdf', 'og-images')).resolves.toBe(false)
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})
it('grants a profile-picture delete only to the owning user', async () => {
mockGetFileMetadata.mockResolvedValue({ userId: USER_ID })
await expect(write('profile-pictures/123-avatar.png', 'profile-pictures')).resolves.toBe(true)
})
it('denies a profile-picture delete for a non-owner', async () => {
mockGetFileMetadata.mockResolvedValue({ userId: 'other-user' })
await expect(write('profile-pictures/123-avatar.png', 'profile-pictures')).resolves.toBe(false)
})
it('grants a workspace-logo delete to write/admin on the owning workspace', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'ws-1' })
mockGetUserEntityPermissions.mockResolvedValue('admin')
await expect(write('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(true)
expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER_ID, 'workspace', 'ws-1')
})
it('denies a workspace-logo delete for a non-member of the owning workspace', async () => {
mockGetFileMetadataByKey.mockResolvedValue({ workspaceId: 'victim-ws' })
mockGetUserEntityPermissions.mockResolvedValue(null)
await expect(write('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(false)
})
it('denies a workspace-logo delete when no ownership binding exists', async () => {
mockGetFileMetadataByKey.mockResolvedValue(null)
await expect(write('workspace-logos/123-logo.png', 'workspace-logos')).resolves.toBe(false)
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})
})