Skip to content

Commit 5465aed

Browse files
fix(knowledge): require workspace or organization ownership (#7635)
* fix(knowledge): require workspace or organization ownership * fix(knowledge): update processing tests for scoped billing
1 parent 044660b commit 5465aed

37 files changed

Lines changed: 26397 additions & 1249 deletions

‎apps/sim/app/api/v1/capability-gate.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ vi.mock('@/lib/table', () => ({
7878
}))
7979
vi.mock('@/lib/table/wire', () => ({ normalizeColumn: (column: unknown) => column }))
8080
vi.mock('@/lib/knowledge/service', () => ({
81-
listWorkspaceAndLegacyKnowledgeBases: mockListKnowledgeBases,
81+
getWorkspaceKnowledgeBases: mockListKnowledgeBases,
8282
getKnowledgeBaseById: vi.fn(),
8383
}))
8484
vi.mock('@/lib/knowledge/orchestration', () => ({ performCreateKnowledgeBase: vi.fn() }))
@@ -158,7 +158,7 @@ beforeEach(() => {
158158
mockGetUserEntityPermissions.mockResolvedValue('admin')
159159
mockGetWorkspaceBillingSettings.mockResolvedValue({ allowPersonalApiKeys: true })
160160
mockListTables.mockResolvedValue([])
161-
mockListKnowledgeBases.mockResolvedValue([])
161+
mockListKnowledgeBases.mockResolvedValue({ data: [], nextCursorKeys: null })
162162
mockListWorkspaceFiles.mockResolvedValue([])
163163
mockListPublicWorkflowLogs.mockResolvedValue({ data: [], nextCursor: null })
164164
mockGetDeploymentWorkflowTarget.mockResolvedValue({

‎apps/sim/app/api/v1/knowledge/route.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from '@/lib/core/orchestration/types'
1111
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1212
import { performCreateKnowledgeBase } from '@/lib/knowledge/orchestration'
13-
import { listWorkspaceAndLegacyKnowledgeBases } from '@/lib/knowledge/service'
13+
import { getWorkspaceKnowledgeBases } from '@/lib/knowledge/service'
1414
import { formatKnowledgeBase, handleError } from '@/app/api/v1/knowledge/utils'
1515
import {
1616
authenticateRequest,
@@ -50,7 +50,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
5050

5151
/** Read only after `validateWorkspaceAccess` authorized this caller; same list the
5252
* internal surface serves, from the same place. */
53-
const knowledgeBases = await listWorkspaceAndLegacyKnowledgeBases(userId, workspaceId)
53+
const { data: knowledgeBases } = await getWorkspaceKnowledgeBases(workspaceId)
5454

5555
return NextResponse.json({
5656
success: true,

‎apps/sim/lib/knowledge/__integration__/organization-mcp-search.integration.ts‎

Lines changed: 80 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ import {
6464
import { confluencePageAcl } from '@/lib/knowledge/access/confluence-permissions'
6565
import { listKnowledgeChunks } from '@/lib/knowledge/application/chunks'
6666
import { readKnowledgeDocument } from '@/lib/knowledge/application/documents'
67+
import { listKnowledgeBaseCatalog } from '@/lib/knowledge/application/knowledge-bases'
68+
import { prepareSearchSource } from '@/lib/knowledge/application/sim-search'
6769
import { searchScopedKnowledge } from '@/lib/knowledge/application/workspace-search'
6870
import { createContentSyncLease } from '@/lib/knowledge/connectors/sync-lock'
6971
import { addDocument, persistDocumentAcls } from '@/lib/knowledge/connectors/sync-persistence'
@@ -84,8 +86,9 @@ describe('organization Search MCP with real ingestion and current access', () =>
8486
groupIds,
8587
} = ids
8688
const otherOrganizationId = generateId()
87-
const otherKnowledgeBaseId = generateId()
89+
const workspaceKnowledgeBaseId = generateId()
8890
const outsiderId = generateId()
91+
const otherAdminId = generateId()
8992
const bobMembershipId = generateId()
9093
const tokens = {
9194
alice: generateId(),
@@ -98,10 +101,16 @@ describe('organization Search MCP with real ingestion and current access', () =>
98101
const clients: Client[] = []
99102
const alicePrincipal: Principal = { kind: 'session', userId: aliceId, sessionId: generateId() }
100103
const bobPrincipal: Principal = { kind: 'session', userId: bobId, sessionId: generateId() }
104+
const otherAdminPrincipal: Principal = {
105+
kind: 'session',
106+
userId: otherAdminId,
107+
sessionId: generateId(),
108+
}
101109
const bobSourceMembership = {
102110
groupId: groupIds[2],
103111
subjectToken: `u:${bobId}@fixture.test`,
104112
}
113+
let otherKnowledgeBaseId: string
105114
let documentId: string
106115
let alice: Client
107116
let bob: Client
@@ -210,14 +219,16 @@ describe('organization Search MCP with real ingestion and current access', () =>
210219
})
211220
fixtures.storageRoot = mkdtempSync(path.join(tmpdir(), 'sim-organization-mcp-integration-'))
212221
await seedKnowledgeAclFixture(ids)
213-
await db.insert(user).values({
214-
id: outsiderId,
215-
name: 'Other organization fixture',
216-
email: `${outsiderId}@fixture.test`,
217-
emailVerified: true,
218-
createdAt: new Date(),
219-
updatedAt: new Date(),
220-
})
222+
await db.insert(user).values(
223+
[outsiderId, otherAdminId].map((id) => ({
224+
id,
225+
name: 'Other organization fixture',
226+
email: `${id}@fixture.test`,
227+
emailVerified: true,
228+
createdAt: new Date(),
229+
updatedAt: new Date(),
230+
}))
231+
)
221232
await db.insert(organization).values({
222233
id: otherOrganizationId,
223234
name: 'Other organization MCP fixture',
@@ -228,6 +239,12 @@ describe('organization Search MCP with real ingestion and current access', () =>
228239
{ id: generateId(), userId: aliceId, organizationId, role: 'owner' },
229240
{ id: bobMembershipId, userId: bobId, organizationId, role: 'member' },
230241
{ id: generateId(), userId: outsiderId, organizationId: otherOrganizationId, role: 'owner' },
242+
{
243+
id: generateId(),
244+
userId: otherAdminId,
245+
organizationId: otherOrganizationId,
246+
role: 'admin',
247+
},
231248
])
232249
/** Reuse source identities, but establish exclusive organization ownership before ingestion. */
233250
await db
@@ -244,12 +261,16 @@ describe('organization Search MCP with real ingestion and current access', () =>
244261
.update(knowledgeExternalGroup)
245262
.set({ workspaceId: null, organizationId })
246263
.where(inArray(knowledgeExternalGroup.id, groupIds))
264+
const prepared = await prepareSearchSource.execute({
265+
principal: otherAdminPrincipal,
266+
input: { organizationId: otherOrganizationId, connectorType: 'gitlab' },
267+
})
268+
otherKnowledgeBaseId = prepared.knowledgeBaseId
247269
await db.insert(knowledgeBase).values({
248-
id: otherKnowledgeBaseId,
249-
userId: outsiderId,
250-
organizationId: otherOrganizationId,
251-
isSearchIndex: true,
252-
name: 'Other org index',
270+
id: workspaceKnowledgeBaseId,
271+
userId: bobId,
272+
workspaceId,
273+
name: 'Workspace documents',
253274
})
254275
await db.insert(organizationSearchIntegration).values({
255276
organizationId,
@@ -363,12 +384,56 @@ describe('organization Search MCP with real ingestion and current access', () =>
363384
.delete(organization)
364385
.where(inArray(organization.id, [organizationId, otherOrganizationId]))
365386
await db.delete(workspace).where(eq(workspace.id, workspaceId))
366-
await db.delete(user).where(inArray(user.id, [aliceId, bobId, outsiderId]))
387+
await db.delete(user).where(inArray(user.id, [aliceId, bobId, outsiderId, otherAdminId]))
367388
if (fixtures.storageRoot) await rm(fixtures.storageRoot, { recursive: true, force: true })
368389
await db.$client.end()
369390
vi.unstubAllGlobals()
370391
})
371392

393+
it('creates an organization-only index, keeps it out of the workspace catalog, and separates actor from payer', async () => {
394+
const input = { organizationId: otherOrganizationId, connectorType: 'gitlab' }
395+
const results = await Promise.all([
396+
prepareSearchSource.execute({ principal: otherAdminPrincipal, input }),
397+
prepareSearchSource.execute({ principal: otherAdminPrincipal, input }),
398+
])
399+
expect(results.map((result) => result.knowledgeBaseId)).toEqual([
400+
otherKnowledgeBaseId,
401+
otherKnowledgeBaseId,
402+
])
403+
const indexes = await db
404+
.select()
405+
.from(knowledgeBase)
406+
.where(eq(knowledgeBase.organizationId, otherOrganizationId))
407+
expect(indexes).toEqual([
408+
expect.objectContaining({
409+
id: otherKnowledgeBaseId,
410+
workspaceId: null,
411+
organizationId: otherOrganizationId,
412+
isSearchIndex: true,
413+
userId: otherAdminId,
414+
}),
415+
])
416+
const catalog = await listKnowledgeBaseCatalog.execute({
417+
principal: alicePrincipal,
418+
input: { workspaceId },
419+
})
420+
expect(catalog.knowledgeBases.map(({ knowledgeBase }) => knowledgeBase.id)).toEqual([
421+
workspaceKnowledgeBaseId,
422+
])
423+
await expect(
424+
resolveOrganizationBillingAttribution({
425+
actorUserId: otherAdminId,
426+
organizationId: otherOrganizationId,
427+
})
428+
).resolves.toMatchObject({
429+
actorUserId: otherAdminId,
430+
workspaceId: null,
431+
organizationId: otherOrganizationId,
432+
billedAccountUserId: outsiderId,
433+
billingEntity: { type: 'organization', id: otherOrganizationId },
434+
})
435+
})
436+
372437
it('finds the canonical org-owned index and applies each current member’s source ACL to all tools', async () => {
373438
const [owner] = await db
374439
.select({

‎apps/sim/lib/knowledge/__integration__/storage-cleanup.integration.ts‎

Lines changed: 35 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
createKnowledgeAclFixtureIds,
3131
seedKnowledgeAclFixture,
3232
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
33-
import { createSingleDocument, hardDeleteDocuments } from '@/lib/knowledge/documents/service'
33+
import { hardDeleteDocuments } from '@/lib/knowledge/documents/service'
3434
import {
3535
cleanupKnowledgeStorage,
3636
enqueueKnowledgeStorageCleanup,
@@ -245,59 +245,42 @@ describe('knowledge backing storage cleanup in PostgreSQL', () => {
245245
}
246246
})
247247

248-
it('cleans a legacy personal KB document using its canonical user-owned binding', async () => {
249-
const fixture = await seed()
250-
await db
251-
.update(knowledgeBase)
252-
.set({ workspaceId: null })
253-
.where(eq(knowledgeBase.id, fixture.knowledgeBaseId))
254-
await db
255-
.update(workspaceFiles)
256-
.set({ workspaceId: null })
257-
.where(eq(workspaceFiles.id, fixture.binding.id))
258-
expect(await hardDeleteDocuments([fixture.docId], 'personal-cleanup')).toBe(1)
259-
const event = await cleanupEvent(fixture.docId)
260-
expect(event.payload).toMatchObject({
261-
userId: fixture.aliceId,
262-
workspaceId: null,
263-
organizationId: null,
264-
})
265-
expect(await processOutboxEventById(event.id, handlers)).toBe('completed')
266-
expect(await getFileMetadataByKeys([fixture.key], 'knowledge-base')).toEqual([])
267-
await expect(access(fixture.filePath)).rejects.toMatchObject({ code: 'ENOENT' })
268-
await expect(
269-
createSingleDocument(
270-
{
271-
filename: 'expired.txt',
272-
fileUrl: fixture.fileUrl,
273-
fileSize: 25,
274-
mimeType: 'text/plain',
248+
it.each([false, true])(
249+
'handles already-queued personal cleanup after KB ownership repair (owner changed: %s)',
250+
async (ownerChanged) => {
251+
const fixture = await seed()
252+
await db.delete(document).where(eq(document.id, fixture.docId))
253+
await db
254+
.update(workspaceFiles)
255+
.set({ workspaceId: null, userId: ownerChanged ? fixture.bobId : fixture.aliceId })
256+
.where(eq(workspaceFiles.id, fixture.binding.id))
257+
const eventId = `knowledge-storage-cleanup:${generateId()}`
258+
events.push(eventId)
259+
await db.insert(outboxEvent).values({
260+
id: eventId,
261+
eventType: KNOWLEDGE_STORAGE_CLEANUP_EVENT,
262+
payload: {
263+
version: 1,
264+
documentId: fixture.docId,
265+
fileId: fixture.binding.id,
266+
key: fixture.key,
267+
contentUpdatedAt: fixture.binding.contentUpdatedAt.toISOString(),
268+
userId: fixture.aliceId,
269+
workspaceId: null,
270+
organizationId: null,
275271
},
276-
fixture.knowledgeBaseId,
277-
'personal-expired-upload',
278-
fixture.aliceId
279-
)
280-
).rejects.toThrow('not owned')
281-
})
272+
})
282273

283-
it('rolls back personal document deletion when the file belongs to a different user', async () => {
284-
const fixture = await seed()
285-
await db
286-
.update(knowledgeBase)
287-
.set({ workspaceId: null })
288-
.where(eq(knowledgeBase.id, fixture.knowledgeBaseId))
289-
await db
290-
.update(workspaceFiles)
291-
.set({ workspaceId: null, userId: fixture.bobId })
292-
.where(eq(workspaceFiles.id, fixture.binding.id))
293-
await expect(hardDeleteDocuments([fixture.docId], 'personal-mismatch')).rejects.toThrow(
294-
'ownership binding'
295-
)
296-
expect(
297-
await db.select({ id: document.id }).from(document).where(eq(document.id, fixture.docId))
298-
).toHaveLength(1)
299-
await expect(access(fixture.filePath)).resolves.toBeUndefined()
300-
})
274+
expect(await processOutboxEventById(eventId, handlers)).toBe('completed')
275+
if (ownerChanged) {
276+
expect(await getFileMetadataByKeys([fixture.key], 'knowledge-base')).toHaveLength(1)
277+
await expect(access(fixture.filePath)).resolves.toBeUndefined()
278+
} else {
279+
expect(await getFileMetadataByKeys([fixture.key], 'knowledge-base')).toEqual([])
280+
await expect(access(fixture.filePath)).rejects.toMatchObject({ code: 'ENOENT' })
281+
}
282+
}
283+
)
301284

302285
it('allows a create-only re-upload to register a new version after cleanup tombstones its old binding', async () => {
303286
const fixture = await seed()

‎apps/sim/lib/knowledge/access/scope.test.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -143,12 +143,10 @@ describe('resolveKnowledgeAccessScope', () => {
143143
})
144144
})
145145

146-
it('does not query for a legacy personal knowledge base', async () => {
147-
await expect(resolveKnowledgeAccessScope(SESSION, {})).resolves.toEqual({
148-
kind: 'user',
149-
userId: 'user-1',
150-
tokens: ['pub', 'ws'],
151-
})
146+
it('rejects missing ownership before querying document access', async () => {
147+
await expect(resolveKnowledgeAccessScope(SESSION, {})).rejects.toThrow(
148+
'Resource requires exactly one workspace or organization owner'
149+
)
152150
expect(dbChainMockFns.select).not.toHaveBeenCalled()
153151
})
154152

‎apps/sim/lib/knowledge/access/scope.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ async function loadExternalGroupTokens(
117117
}
118118

119119
export interface KnowledgeAccessScopeContext {
120-
/** Undefined only for a legacy personal knowledge base, which cannot own connectors. */
120+
/** Exactly one workspace or organization owner is required at resolution. */
121121
workspaceId?: string
122122
organizationId?: string
123123
}
@@ -136,7 +136,6 @@ async function loadUserAccessTokens(
136136
context: KnowledgeAccessScopeContext
137137
): Promise<string[]> {
138138
const { workspaceId, organizationId } = context
139-
if (!workspaceId && !organizationId) return [...WORKSPACE_ACCESS_TOKENS]
140139
const scope = resourceScopeFromOwner(context)
141140
const baseline = organizationId ? ORGANIZATION_ACCESS_TOKENS : WORKSPACE_ACCESS_TOKENS
142141

@@ -277,6 +276,7 @@ export async function resolveKnowledgeAccessScope(
277276
'Credential Group enrollments cannot read knowledge documents'
278277
)
279278
}
279+
resourceScopeFromOwner(context)
280280
const subject = resolvePrincipalSubject(principal)
281281
if (subject?.kind !== 'sim_user') {
282282
if (context.organizationId)

‎apps/sim/lib/knowledge/application/authorization.ts‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,6 @@ export interface KnowledgeAuthorizationContext
2020
organizationId?: undefined
2121
}
2222

23-
export interface LegacyPersonalKnowledgeAuthorizationContext extends KnowledgeResourceIdentifiers {
24-
workspaceId: undefined
25-
organizationId?: undefined
26-
legacyPersonalOwnerUserId: string
27-
}
28-
2923
export interface KnowledgeOrganizationAuthorizationContext extends KnowledgeResourceIdentifiers {
3024
organizationId: string
3125
workspaceId: undefined
@@ -34,7 +28,6 @@ export interface KnowledgeOrganizationAuthorizationContext extends KnowledgeReso
3428
export type KnowledgeResourceAuthorizationContext =
3529
| KnowledgeAuthorizationContext
3630
| KnowledgeOrganizationAuthorizationContext
37-
| LegacyPersonalKnowledgeAuthorizationContext
3831

3932
export type KnowledgeAuthorizationOptions = Omit<
4033
WorkspaceAuthorizationOptions<KnowledgeAuthorizationContext>,

0 commit comments

Comments
 (0)