Skip to content

Commit cbf3fb9

Browse files
committed
fix(knowledge): expose authorize on knowledge use cases and gate export on describable bundles
- defineAuthorizedKnowledgeUseCase now answers authorize() through the same resolution execute() uses, which headSafe: false routes require at module init; defineAuthorizedWorkspaceUseCase's return type states that authorize is always present - the export use case validates the stored base against the bundle manifest schema before any byte streams, so a value the import side would refuse surfaces as a 409 rather than a truncated archive - Export is hidden in the base menu and header when a permission group withholds knowledge.export - operations id snapshot includes knowledge.export
1 parent 6274e79 commit cbf3fb9

9 files changed

Lines changed: 172 additions & 51 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,7 @@ import { useDebounce } from '@/hooks/use-debounce'
131131
import { useDebouncedSearchSetter } from '@/hooks/use-debounced-search-setter'
132132
import { useInlineRename } from '@/hooks/use-inline-rename'
133133
import { useOAuthReturnForKBConnectors } from '@/hooks/use-oauth-return'
134+
import { usePermissionConfig } from '@/hooks/use-permission-config'
134135
import { useUrlSort } from '@/hooks/use-url-sort'
135136

136137
const logger = createLogger('KnowledgeBase')
@@ -315,6 +316,7 @@ export function KnowledgeBase({
315316

316317
useOAuthReturnForKBConnectors(id)
317318
const userPermissions = useUserPermissionsContext()
319+
const { config: permissionConfig } = usePermissionConfig()
318320

319321
const { mutate: updateDocumentMutation, mutateAsync: updateDocumentAsync } = useUpdateDocument()
320322
const { mutate: deleteDocumentMutation } = useDeleteDocument()
@@ -1011,11 +1013,9 @@ export function KnowledgeBase({
10111013

10121014
const headerActions: ResourceAction[] = useMemo(
10131015
() => [
1014-
{
1015-
text: 'Export',
1016-
icon: Download,
1017-
onSelect: () => downloadKnowledgeBaseExport(id),
1018-
},
1016+
...(permissionConfig.disableKnowledgeBaseExport
1017+
? []
1018+
: [{ text: 'Export', icon: Download, onSelect: () => downloadKnowledgeBaseExport(id) }]),
10191019
...(userPermissions.canEdit || userPermissions.isLoading
10201020
? [
10211021
{
@@ -1036,6 +1036,7 @@ export function KnowledgeBase({
10361036
],
10371037
[
10381038
id,
1039+
permissionConfig.disableKnowledgeBaseExport,
10391040
userPermissions.canEdit,
10401041
userPermissions.isLoading,
10411042
setShowAddConnectorModal,

‎apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1545,7 +1545,7 @@ export function Knowledge() {
15451545
onOpenInNewTab={handleOpenInNewTab}
15461546
onViewTags={handleViewTags}
15471547
onCopyId={handleCopyId}
1548-
onExport={handleExport}
1548+
onExport={permissionConfig.disableKnowledgeBaseExport ? undefined : handleExport}
15491549
onTogglePin={handleToggleBasePin}
15501550
pinned={pinnedBaseIds.has(activeKnowledgeBase.id)}
15511551
onEdit={handleEdit}

‎apps/sim/lib/core/application/authorized-workspace-use-case.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,26 @@ export function recordProjectedUseCaseAuditEntries(
122122
}
123123
}
124124

125+
/**
126+
* A workspace use case always answers `authorize`, so a caller that must run
127+
* the funnel without executing (a `HEAD`, or a wrapping domain builder) can
128+
* rely on it without a runtime guard.
129+
*/
130+
export type AuthorizedWorkspaceUseCase<O extends WorkspaceOperation, I, R> = OperationUseCase<
131+
O,
132+
I,
133+
R
134+
> &
135+
Required<Pick<OperationUseCase<O, I, R>, 'authorize'>>
136+
125137
export function defineAuthorizedWorkspaceUseCase<
126138
const O extends WorkspaceOperation,
127139
I,
128140
C extends WorkspaceAuthorizationContext,
129141
R,
130-
>(definition: AuthorizedWorkspaceUseCaseDefinition<O, I, C, R>): OperationUseCase<O, I, R> {
142+
>(
143+
definition: AuthorizedWorkspaceUseCaseDefinition<O, I, C, R>
144+
): AuthorizedWorkspaceUseCase<O, I, R> {
131145
const resourceAuthorization = (() => {
132146
const { authorizeResource, operation } = definition
133147
const resourcePolicy = ('resourcePolicy' in operation ? operation.resourcePolicy : undefined) as

‎apps/sim/lib/knowledge/application/authorized-knowledge-use-case.ts‎

Lines changed: 78 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -134,41 +134,89 @@ export function defineAuthorizedKnowledgeUseCase<
134134
}
135135
)
136136

137+
/**
138+
* Resolves and authorizes the context every branch below shares, so `authorize`
139+
* and `execute` cannot drift apart. An organization base is fully authorized
140+
* here; a workspace base still owes the workspace funnel, which the returned
141+
* scope tells the caller to run.
142+
*/
143+
async function resolveAuthorizedContext({
144+
principal,
145+
input,
146+
}: {
147+
principal: Principal
148+
input: I
149+
}): Promise<
150+
| { scope: 'organization'; principal: KnowledgePrincipalForOperation<O>; context: C }
151+
| {
152+
scope: 'workspace'
153+
principal: KnowledgePrincipalForOperation<O>
154+
context: WorkspaceContext
155+
}
156+
> {
157+
requireKnowledgePrincipal(principal, definition.operation)
158+
const context = await definition.resolveContext({ principal, input })
159+
if (context.organizationId) {
160+
await authorizeOrganizationOperation(
161+
principal,
162+
definition.operation.organizationOperation,
163+
context
164+
)
165+
return { scope: 'organization', principal, context }
166+
}
167+
if (principal.kind === 'organization_delegated')
168+
throw new OrchestrationError('not_found', 'Knowledge base not found')
169+
assertWorkspaceKnowledgeContext(context)
170+
return { scope: 'workspace', principal, context }
171+
}
172+
173+
function recordAudit(resultContext: AuthorizedKnowledgeUseCaseResultContext<O, I, C, R>): void {
174+
const projectedAudit = definition.projectAudit?.(resultContext)
175+
if (projectedAudit === undefined) return
176+
const auditEntries = Array.isArray(projectedAudit) ? projectedAudit : [projectedAudit]
177+
if (auditEntries.length === 0) return
178+
const organizationId = resultContext.context.organizationId ?? undefined
179+
recordProjectedUseCaseAuditEntries(
180+
definition.operation,
181+
organizationId ? undefined : resultContext.context.workspaceId,
182+
resultContext.principal,
183+
resultContext.request,
184+
auditEntries,
185+
organizationId
186+
)
187+
}
188+
137189
return {
138190
operation: definition.operation,
191+
async authorize({ principal, input, request }) {
192+
const resolved = await resolveAuthorizedContext({ principal, input })
193+
if (resolved.scope !== 'workspace') return
194+
await workspaceUseCase.authorize({
195+
principal: resolved.principal,
196+
input: { originalInput: input, context: resolved.context },
197+
request,
198+
})
199+
},
139200
async execute({ principal, input, request }) {
140-
requireKnowledgePrincipal(principal, definition.operation)
141-
const context = await definition.resolveContext({ principal, input })
142-
if (context.organizationId) {
143-
await authorizeOrganizationOperation(
144-
principal,
145-
definition.operation.organizationOperation,
146-
context
147-
)
148-
const result = await definition.execute({ principal, input, context, request })
149-
const resultContext = { principal, input, context, request, result }
150-
const projectedAudit = definition.projectAudit?.(resultContext)
151-
if (projectedAudit !== undefined) {
152-
recordProjectedUseCaseAuditEntries(
153-
definition.operation,
154-
undefined,
155-
principal,
156-
request,
157-
Array.isArray(projectedAudit) ? projectedAudit : [projectedAudit],
158-
context.organizationId
159-
)
160-
}
161-
await definition.afterSuccess?.(resultContext)
162-
return result
201+
const resolved = await resolveAuthorizedContext({ principal, input })
202+
if (resolved.scope === 'workspace') {
203+
return workspaceUseCase.execute({
204+
principal: resolved.principal,
205+
input: { originalInput: input, context: resolved.context },
206+
request,
207+
})
163208
}
164-
if (principal.kind === 'organization_delegated')
165-
throw new OrchestrationError('not_found', 'Knowledge base not found')
166-
assertWorkspaceKnowledgeContext(context)
167-
return workspaceUseCase.execute({
168-
principal,
169-
input: { originalInput: input, context },
209+
const executionContext = {
210+
principal: resolved.principal,
211+
input,
212+
context: resolved.context,
170213
request,
171-
})
214+
}
215+
const result = await definition.execute(executionContext)
216+
const resultContext = { ...executionContext, result }
217+
recordAudit(resultContext)
218+
await definition.afterSuccess?.(resultContext)
219+
return result
172220
},
173221
}
174222
}

‎apps/sim/lib/knowledge/application/exports.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,21 @@ describe('exportKnowledgeBase', () => {
181181
expect(mocks.recordAudit).not.toHaveBeenCalled()
182182
})
183183

184+
/** The manifest is written last, so a value the format rejects must fail before any byte streams. */
185+
it('refuses a base whose stored values the bundle format cannot describe', async () => {
186+
mocks.listDocuments.mockResolvedValueOnce([
187+
{ ...documents[0], tags: { tag1: 'x'.repeat(10_001) } },
188+
])
189+
190+
await expect(
191+
exportKnowledgeBase.execute({
192+
principal,
193+
input: { knowledgeBaseId: 'knowledge-1', vectors: true },
194+
})
195+
).rejects.toMatchObject({ code: 'conflict' })
196+
expect(mocks.recordAudit).not.toHaveBeenCalled()
197+
})
198+
184199
it('propagates an oversized base without recording audit', async () => {
185200
mocks.listDocuments.mockRejectedValueOnce(
186201
new OrchestrationError('payload_too_large', 'Knowledge base has 2001 documents')

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,15 @@ import type { Principal } from '@sim/auth/principal'
33
import { defineAuthorizedKnowledgeUseCase } from '@/lib/knowledge/application/authorized-knowledge-use-case'
44
import { resolveActiveKnowledgeBaseContext } from '@/lib/knowledge/application/contexts'
55
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
6+
import { KNOWLEDGE_BUNDLE_VERSION } from '@/lib/knowledge/constants'
67
import { toKbEmbeddingDimensions } from '@/lib/knowledge/embedding-models'
7-
import type { KnowledgeBundleManifest, KnowledgeBundleTag } from '@/lib/knowledge/transfer/bundle'
8+
import {
9+
assertDescribableByBundle,
10+
bundleEntryPaths,
11+
type KnowledgeBundleManifest,
12+
type KnowledgeBundleTag,
13+
toManifestDocument,
14+
} from '@/lib/knowledge/transfer/bundle'
815
import {
916
type ExportableChunk,
1017
type ExportableDocument,
@@ -50,7 +57,7 @@ export const exportKnowledgeBase = defineAuthorizedKnowledgeUseCase({
5057
listExportableTags(knowledgeBase.id),
5158
listExportableDocuments(knowledgeBase.id),
5259
])
53-
return {
60+
const bundle: KnowledgeBaseExportBundle = {
5461
knowledgeBase: {
5562
name: knowledgeBase.name,
5663
description: knowledgeBase.description,
@@ -65,6 +72,17 @@ export const exportKnowledgeBase = defineAuthorizedKnowledgeUseCase({
6572
documents,
6673
chunks: (documentId) => iterateDocumentChunks(documentId, input.vectors ? dimension : null),
6774
}
75+
assertDescribableByBundle({
76+
version: KNOWLEDGE_BUNDLE_VERSION,
77+
exportedAt: new Date().toISOString(),
78+
embedding: bundle.embedding,
79+
knowledgeBase: bundle.knowledgeBase,
80+
tags,
81+
documents: documents.map((document) =>
82+
toManifestDocument(document, bundleEntryPaths(document), 0)
83+
),
84+
})
85+
return bundle
6886
},
6987
projectAudit: ({ context, input, result }) => ({
7088
action: AuditAction.KNOWLEDGE_BASE_EXPORTED,

‎apps/sim/lib/knowledge/application/operations.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ describe('knowledge operation registry', () => {
1212
expect(ids).toEqual([
1313
'knowledge.list',
1414
'knowledge.read',
15+
'knowledge.export',
1516
'knowledge.create',
1617
'knowledge.update',
1718
'knowledge.delete',

‎apps/sim/lib/knowledge/transfer/bundle.ts‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { z } from 'zod'
22
import { chunkingConfigSchema } from '@/lib/api/contracts/knowledge/base'
3+
import { OrchestrationError } from '@/lib/core/orchestration/types'
34
import { KB_EMBEDDING_STORAGE_DIMENSIONS } from '@/lib/embeddings/catalog'
45
import {
56
ALL_TAG_SLOTS,
@@ -13,6 +14,7 @@ import {
1314
SUPPORTED_FIELD_TYPES,
1415
} from '@/lib/knowledge/constants'
1516
import { MAX_DOCUMENT_CHUNKS } from '@/lib/knowledge/documents/document-processing-error'
17+
import type { ExportableDocument } from '@/lib/knowledge/transfer/export-source'
1618
import { MAX_KNOWLEDGE_DOCUMENT_FILE_SIZE } from '@/lib/uploads/shared/types'
1719
import { safeZipLeafName } from '@/lib/uploads/zip-entry-path'
1820

@@ -69,7 +71,7 @@ export const knowledgeBundleTagSchema = z
6971
message: 'Tag slot does not belong to its field type',
7072
})
7173

72-
const bundleDocumentSchema = z
74+
export const knowledgeBundleDocumentSchema = z
7375
.object({
7476
id: z.string().regex(BUNDLE_DOCUMENT_ID_PATTERN, 'Document id must be a short identifier'),
7577
filename: z.string().min(1).max(MAX_BUNDLE_TEXT_LENGTH),
@@ -151,7 +153,7 @@ export const knowledgeBundleManifestSchema = z
151153
names.add(name)
152154
}
153155
}),
154-
documents: z.array(bundleDocumentSchema).max(MAX_KNOWLEDGE_BUNDLE_DOCUMENTS),
156+
documents: z.array(knowledgeBundleDocumentSchema).max(MAX_KNOWLEDGE_BUNDLE_DOCUMENTS),
155157
})
156158
.strict()
157159

@@ -207,6 +209,31 @@ export function fileEntryPath(documentId: string, filename: string): string {
207209
return `files/${documentId}/${safeBundleLeafName(filename)}`
208210
}
209211

212+
/** Where an exportable document's entries sit inside the bundle, or `null` for entries it does not carry. */
213+
export function bundleEntryPaths(
214+
document: Pick<ExportableDocument, 'id' | 'filename' | 'file' | 'hasChunks'>
215+
): KnowledgeBundleEntryPaths {
216+
return {
217+
file: document.file ? fileEntryPath(document.id, document.filename) : null,
218+
chunks: document.hasChunks ? chunksEntryPath(document.id) : null,
219+
}
220+
}
221+
222+
/**
223+
* Refuses an export whose stored values the bundle format cannot describe,
224+
* before any byte streams: the manifest is written last, so a value the import
225+
* side would reject must surface as a clear error rather than a truncated archive.
226+
*/
227+
export function assertDescribableByBundle(manifest: unknown): void {
228+
const result = knowledgeBundleManifestSchema.safeParse(manifest)
229+
if (result.success) return
230+
const [issue] = result.error.issues
231+
throw new OrchestrationError(
232+
'conflict',
233+
`Knowledge base cannot be exported: ${issue.path.join('.')} ${issue.message}`
234+
)
235+
}
236+
210237
/** A filesystem-safe leaf name for a bundle entry or the bundle download itself. */
211238
export function safeBundleLeafName(name: string): string {
212239
return safeZipLeafName(name).slice(0, MAX_BUNDLE_LEAF_NAME_LENGTH)

‎apps/sim/lib/knowledge/transfer/export-archive.ts‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@ import { decodeDataUriWithinLimit } from '@/lib/file-parsers/data-uri'
77
import type { KnowledgeBaseExportBundle } from '@/lib/knowledge/application/exports'
88
import { KNOWLEDGE_BUNDLE_VERSION } from '@/lib/knowledge/constants'
99
import {
10-
chunksEntryPath,
10+
bundleEntryPaths,
1111
encodeVectorBase64,
12-
fileEntryPath,
1312
KNOWLEDGE_BUNDLE_MANIFEST_ENTRY,
1413
type KnowledgeBundleChunkLine,
1514
type KnowledgeBundleDocument,
@@ -96,21 +95,19 @@ async function appendBundleEntries(
9695
): Promise<void> {
9796
const documents: KnowledgeBundleDocument[] = []
9897
for (const document of bundle.documents) {
99-
let file: string | null = null
100-
if (document.file) {
101-
file = fileEntryPath(document.id, document.filename)
102-
await appendEntry(archive, await openFileSource(document.file), file)
98+
const entries = bundleEntryPaths(document)
99+
if (document.file && entries.file) {
100+
await appendEntry(archive, await openFileSource(document.file), entries.file)
103101
}
104-
const chunks = document.hasChunks ? chunksEntryPath(document.id) : null
105-
const chunkCount = chunks
102+
const chunkCount = entries.chunks
106103
? await appendChunkEntry(
107104
archive,
108105
bundle.chunks(document.id),
109106
bundle.embedding.vectorsIncluded,
110-
chunks
107+
entries.chunks
111108
)
112109
: 0
113-
documents.push(toManifestDocument(document, { file, chunks }, chunkCount))
110+
documents.push(toManifestDocument(document, entries, chunkCount))
114111
}
115112

116113
const manifest: KnowledgeBundleManifest = {

0 commit comments

Comments
 (0)