Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 67 additions & 3 deletions apps/sim/lib/knowledge/service.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
/**
* @vitest-environment node
*/
import { dbChainMockFns, permissionsMock, permissionsMockFns, resetDbChainMock } from '@sim/testing'
import {
dbChainMockFns,
flattenMockConditions,
hasMockCondition,
permissionsMock,
permissionsMockFns,
resetDbChainMock,
schemaMock,
} from '@sim/testing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

const {
Expand Down Expand Up @@ -31,7 +39,63 @@ vi.mock('@/lib/billing/core/usage', () => ({
ensureUserStatsExists: mockEnsureUserStatsExists,
}))

import { KnowledgeBasePermissionError, updateKnowledgeBase } from '@/lib/knowledge/service'
import {
getKnowledgeBases,
KnowledgeBasePermissionError,
updateKnowledgeBase,
} from '@/lib/knowledge/service'

/**
* The listing query authorizes on current workspace membership, never on stale creator
* identity: a user removed from a workspace must stop seeing knowledge bases they created
* there. The creator fallback exists only for legacy knowledge bases with no `workspaceId`.
*/
describe('getKnowledgeBases — creator fallback is scoped to legacy non-workspace KBs', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})

/** Every disjunct that grants on `knowledgeBase.userId`, from the last select chain's WHERE. */
const capturedCreatorBranches = (): unknown[] => {
const [condition] = dbChainMockFns.where.mock.calls.at(-1) ?? []
const orNode = flattenMockConditions(condition).find((node) => node.type === 'or')
expect(orNode, 'WHERE clause has no or(...) branch').toBeDefined()
return (orNode?.conditions as unknown[]).filter((disjunct) =>
hasMockCondition(
disjunct,
(node) =>
node.type === 'eq' &&
node.left === schemaMock.knowledgeBase.userId &&
node.right === 'user-a'
)
)
}

/** The creator fallback must be the sole grant for legacy KBs and never reach workspace KBs. */
const expectCreatorBranchIsLegacyOnly = () => {
const branches = capturedCreatorBranches()
expect(branches).toHaveLength(1)
expect(
hasMockCondition(
branches[0],
(node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.workspaceId
)
).toBe(true)
}

it('requires workspaceId IS NULL on the creator branch when no workspace filter is given', async () => {
await getKnowledgeBases('user-a', undefined, 'all')

expectCreatorBranchIsLegacyOnly()
})

it('keeps the same guard on the workspace-filtered branch', async () => {
await getKnowledgeBases('user-a', 'ws-1', 'active')

expectCreatorBranchIsLegacyOnly()
})
})

/**
* These tests guard the workspace mass-assignment fix:
Expand Down Expand Up @@ -82,7 +146,7 @@ describe('updateKnowledgeBase — workspace transfer authorization', () => {

await expect(
updateKnowledgeBase('kb-1', { workspaceId: null }, 'req-1', { actorUserId: 'owner' })
).rejects.not.toBeInstanceOf(KnowledgeBasePermissionError)
).resolves.toBeDefined()
expect(permissionsMockFns.mockGetUserEntityPermissions).not.toHaveBeenCalled()
})

Expand Down
41 changes: 22 additions & 19 deletions apps/sim/lib/knowledge/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,21 @@ export async function getKnowledgeBases(
? sql`${knowledgeBase.deletedAt} IS NOT NULL`
: isNull(knowledgeBase.deletedAt)

/**
* Legacy knowledge bases predate workspaces and have no `workspaceId`, so the creator is
* their only possible authority. Anything with a `workspaceId` must clear
* `currentWorkspaceMembership` instead — creator identity goes stale the moment a member
* is removed from the workspace.
*/
const legacyOwnedKnowledgeBase = and(
eq(knowledgeBase.userId, userId),
isNull(knowledgeBase.workspaceId)
)
const currentWorkspaceMembership = and(
isNotNull(permissions.userId),
isNull(workspace.archivedAt)
)

const knowledgeBasesWithCounts = await db
.select({
id: knowledgeBase.id,
Expand Down Expand Up @@ -143,25 +158,13 @@ export async function getKnowledgeBases(
.where(
and(
scopeCondition,
workspaceId
? // When filtering by workspace
or(
// Knowledge bases belonging to the specified workspace (user must have workspace permissions)
and(
eq(knowledgeBase.workspaceId, workspaceId),
isNotNull(permissions.userId),
isNull(workspace.archivedAt)
),
// Fallback: User-owned knowledge bases without workspace (legacy)
and(eq(knowledgeBase.userId, userId), isNull(knowledgeBase.workspaceId))
)
: // When not filtering by workspace, use original logic
or(
// User owns the knowledge base directly
eq(knowledgeBase.userId, userId),
// User has permissions on the knowledge base's workspace
and(isNotNull(permissions.userId), isNull(workspace.archivedAt))
)
or(
and(
workspaceId ? eq(knowledgeBase.workspaceId, workspaceId) : undefined,
currentWorkspaceMembership
),
legacyOwnedKnowledgeBase
)
)
)
.groupBy(knowledgeBase.id)
Expand Down
2 changes: 2 additions & 0 deletions packages/testing/src/mocks/database.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export function createMockSql() {
toSQL: () => ({ sql: strings.join('?'), params: values }),
/** Mirrors drizzle's `sql``…`.as(alias)` for aliased select expressions. */
as: (alias: string) => ({ ...fragment, alias }),
/** Mirrors drizzle's `sql``…`.mapWith(decoder)` for typed select expressions. */
mapWith: (decoder: unknown) => ({ ...fragment, decoder }),
}
return fragment
}
Expand Down
Loading