Skip to content

Commit 9883543

Browse files
authored
fix(knowledge): stop listing workspace knowledge bases on stale creator identity (#6454)
GET /api/knowledge without a workspaceId ORed on knowledge_base.user_id with no permission check, so a user removed from a workspace kept seeing metadata for every KB they created there. Scope the creator fallback to legacy KBs with no workspaceId, matching the workspace-filtered branch and the detail path.
1 parent a477a52 commit 9883543

3 files changed

Lines changed: 91 additions & 22 deletions

File tree

apps/sim/lib/knowledge/service.test.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
/**
22
* @vitest-environment node
33
*/
4-
import { dbChainMockFns, permissionsMock, permissionsMockFns, resetDbChainMock } from '@sim/testing'
4+
import {
5+
dbChainMockFns,
6+
flattenMockConditions,
7+
hasMockCondition,
8+
permissionsMock,
9+
permissionsMockFns,
10+
resetDbChainMock,
11+
schemaMock,
12+
} from '@sim/testing'
513
import { beforeEach, describe, expect, it, vi } from 'vitest'
614

715
const {
@@ -31,7 +39,63 @@ vi.mock('@/lib/billing/core/usage', () => ({
3139
ensureUserStatsExists: mockEnsureUserStatsExists,
3240
}))
3341

34-
import { KnowledgeBasePermissionError, updateKnowledgeBase } from '@/lib/knowledge/service'
42+
import {
43+
getKnowledgeBases,
44+
KnowledgeBasePermissionError,
45+
updateKnowledgeBase,
46+
} from '@/lib/knowledge/service'
47+
48+
/**
49+
* The listing query authorizes on current workspace membership, never on stale creator
50+
* identity: a user removed from a workspace must stop seeing knowledge bases they created
51+
* there. The creator fallback exists only for legacy knowledge bases with no `workspaceId`.
52+
*/
53+
describe('getKnowledgeBases — creator fallback is scoped to legacy non-workspace KBs', () => {
54+
beforeEach(() => {
55+
vi.clearAllMocks()
56+
resetDbChainMock()
57+
})
58+
59+
/** Every disjunct that grants on `knowledgeBase.userId`, from the last select chain's WHERE. */
60+
const capturedCreatorBranches = (): unknown[] => {
61+
const [condition] = dbChainMockFns.where.mock.calls.at(-1) ?? []
62+
const orNode = flattenMockConditions(condition).find((node) => node.type === 'or')
63+
expect(orNode, 'WHERE clause has no or(...) branch').toBeDefined()
64+
return (orNode?.conditions as unknown[]).filter((disjunct) =>
65+
hasMockCondition(
66+
disjunct,
67+
(node) =>
68+
node.type === 'eq' &&
69+
node.left === schemaMock.knowledgeBase.userId &&
70+
node.right === 'user-a'
71+
)
72+
)
73+
}
74+
75+
/** The creator fallback must be the sole grant for legacy KBs and never reach workspace KBs. */
76+
const expectCreatorBranchIsLegacyOnly = () => {
77+
const branches = capturedCreatorBranches()
78+
expect(branches).toHaveLength(1)
79+
expect(
80+
hasMockCondition(
81+
branches[0],
82+
(node) => node.type === 'isNull' && node.column === schemaMock.knowledgeBase.workspaceId
83+
)
84+
).toBe(true)
85+
}
86+
87+
it('requires workspaceId IS NULL on the creator branch when no workspace filter is given', async () => {
88+
await getKnowledgeBases('user-a', undefined, 'all')
89+
90+
expectCreatorBranchIsLegacyOnly()
91+
})
92+
93+
it('keeps the same guard on the workspace-filtered branch', async () => {
94+
await getKnowledgeBases('user-a', 'ws-1', 'active')
95+
96+
expectCreatorBranchIsLegacyOnly()
97+
})
98+
})
3599

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

83147
await expect(
84148
updateKnowledgeBase('kb-1', { workspaceId: null }, 'req-1', { actorUserId: 'owner' })
85-
).rejects.not.toBeInstanceOf(KnowledgeBasePermissionError)
149+
).resolves.toBeDefined()
86150
expect(permissionsMockFns.mockGetUserEntityPermissions).not.toHaveBeenCalled()
87151
})
88152

apps/sim/lib/knowledge/service.ts

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,21 @@ export async function getKnowledgeBases(
104104
? sql`${knowledgeBase.deletedAt} IS NOT NULL`
105105
: isNull(knowledgeBase.deletedAt)
106106

107+
/**
108+
* Legacy knowledge bases predate workspaces and have no `workspaceId`, so the creator is
109+
* their only possible authority. Anything with a `workspaceId` must clear
110+
* `currentWorkspaceMembership` instead — creator identity goes stale the moment a member
111+
* is removed from the workspace.
112+
*/
113+
const legacyOwnedKnowledgeBase = and(
114+
eq(knowledgeBase.userId, userId),
115+
isNull(knowledgeBase.workspaceId)
116+
)
117+
const currentWorkspaceMembership = and(
118+
isNotNull(permissions.userId),
119+
isNull(workspace.archivedAt)
120+
)
121+
107122
const knowledgeBasesWithCounts = await db
108123
.select({
109124
id: knowledgeBase.id,
@@ -143,25 +158,13 @@ export async function getKnowledgeBases(
143158
.where(
144159
and(
145160
scopeCondition,
146-
workspaceId
147-
? // When filtering by workspace
148-
or(
149-
// Knowledge bases belonging to the specified workspace (user must have workspace permissions)
150-
and(
151-
eq(knowledgeBase.workspaceId, workspaceId),
152-
isNotNull(permissions.userId),
153-
isNull(workspace.archivedAt)
154-
),
155-
// Fallback: User-owned knowledge bases without workspace (legacy)
156-
and(eq(knowledgeBase.userId, userId), isNull(knowledgeBase.workspaceId))
157-
)
158-
: // When not filtering by workspace, use original logic
159-
or(
160-
// User owns the knowledge base directly
161-
eq(knowledgeBase.userId, userId),
162-
// User has permissions on the knowledge base's workspace
163-
and(isNotNull(permissions.userId), isNull(workspace.archivedAt))
164-
)
161+
or(
162+
and(
163+
workspaceId ? eq(knowledgeBase.workspaceId, workspaceId) : undefined,
164+
currentWorkspaceMembership
165+
),
166+
legacyOwnedKnowledgeBase
167+
)
165168
)
166169
)
167170
.groupBy(knowledgeBase.id)

packages/testing/src/mocks/database.mock.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ export function createMockSql() {
2929
toSQL: () => ({ sql: strings.join('?'), params: values }),
3030
/** Mirrors drizzle's `sql``…`.as(alias)` for aliased select expressions. */
3131
as: (alias: string) => ({ ...fragment, alias }),
32+
/** Mirrors drizzle's `sql``…`.mapWith(decoder)` for typed select expressions. */
33+
mapWith: (decoder: unknown) => ({ ...fragment, decoder }),
3234
}
3335
return fragment
3436
}

0 commit comments

Comments
 (0)