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
213 lines
8.5 KiB
TypeScript
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()
|
|
})
|
|
})
|