Skip to content

Commit 451258c

Browse files
feat(slack): add personalized sources to app Home
1 parent bec75cf commit 451258c

15 files changed

Lines changed: 1036 additions & 45 deletions

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

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,10 @@ import type { ScopedKnowledgeOperation } from '@/lib/knowledge/application/opera
2121

2222
type KnowledgePrincipalForOperation<O extends ScopedKnowledgeOperation> =
2323
| PrincipalForOperation<O>
24-
| ('copilot' extends NonNullable<O['delegatedServices']>[number]
25-
? O['minimumRole'] extends 'read'
26-
? OrganizationDelegatedPrincipal
27-
: never
28-
: never)
24+
| Extract<
25+
OrganizationDelegatedPrincipal,
26+
{ serviceId: NonNullable<O['organizationOperation']['delegatedServices']>[number] }
27+
>
2928

3029
function requireKnowledgePrincipal<O extends ScopedKnowledgeOperation>(
3130
principal: Principal,

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

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,15 @@ import { describe, expect, it } from 'vitest'
77
import { knowledgeOperations } from '@/lib/knowledge/application/operations'
88

99
describe('knowledge operation registry', () => {
10-
it('limits Slack member delegation to the existing search operation', () => {
10+
it('limits Slack member delegation to search and the personalized source list', () => {
1111
const allowed = Object.values(knowledgeOperations).filter((operation) =>
1212
operation.organizationOperation.delegatedServices?.includes('slack-search')
1313
)
14-
expect(allowed).toEqual([knowledgeOperations.search])
14+
expect(allowed).toEqual([knowledgeOperations.search, knowledgeOperations.listSearchSources])
15+
expect(knowledgeOperations.listSearchSources.organizationOperation.delegatedServices).toEqual([
16+
'slack-search',
17+
])
18+
expect(knowledgeOperations.listSearchSources.principalKinds).toEqual(['session'])
1519
})
1620
it('defines unique stable semantic operation IDs', () => {
1721
const ids = Object.values(knowledgeOperations).map((operation) => operation.id)
@@ -144,12 +148,13 @@ describe('knowledge operation registry', () => {
144148
}
145149
})
146150

147-
it('permits organization delegation only for Copilot reads', () => {
151+
it('permits organization delegation only for explicitly delegated reads', () => {
148152
for (const operation of Object.values(knowledgeOperations)) {
149153
if (!operation.organizationOperation.principalKinds.includes('organization_delegated'))
150154
continue
151155
expect(operation.minimumRole).toBe('read')
152-
expect(operation.delegatedServices).toContain('copilot')
156+
if (operation !== knowledgeOperations.listSearchSources)
157+
expect(operation.delegatedServices).toContain('copilot')
153158
expect(operation.organizationOperation.delegationAudience).toBe('sim:knowledge')
154159
}
155160
expect(knowledgeOperations.search.organizationOperation.principalKinds).toContain(

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

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { OrganizationDelegatedPrincipal } from '@sim/auth/principal'
12
import {
23
type ApplicationOperation,
34
assertOperationCapability,
@@ -15,19 +16,47 @@ export type ScopedKnowledgeOperation<O extends WorkspaceOperation = WorkspaceOpe
1516
readonly organizationOperation: OrganizationOperation
1617
}
1718

18-
interface KnowledgeOperationOptions {
19+
interface KnowledgeOperationOptions<
20+
Services extends readonly OrganizationDelegatedPrincipal['serviceId'][],
21+
> {
1922
organizationDelegation?: 'deny'
23+
organizationDelegatedServices?: Services
24+
}
25+
26+
type DelegatingKnowledgeOperation<
27+
O extends WorkspaceOperation,
28+
Services extends readonly OrganizationDelegatedPrincipal['serviceId'][],
29+
> = ScopedKnowledgeOperation<O> & {
30+
readonly organizationOperation: {
31+
readonly delegatedServices?: readonly (
32+
| Services[number]
33+
| ('copilot' extends NonNullable<O['delegatedServices']>[number]
34+
? O['minimumRole'] extends 'read'
35+
? OrganizationDelegatedPrincipal['serviceId']
36+
: never
37+
: never)
38+
)[]
39+
}
2040
}
2141

2242
/** Binds organization policy to the same semantic operation declared for workspace access. */
23-
function defineKnowledgeOperation<const O extends WorkspaceOperation>(
43+
function defineKnowledgeOperation<
44+
const O extends WorkspaceOperation,
45+
const Services extends readonly OrganizationDelegatedPrincipal['serviceId'][] = readonly [],
46+
>(
2447
operation: O,
25-
options?: KnowledgeOperationOptions
26-
): ScopedKnowledgeOperation<O> {
48+
options?: KnowledgeOperationOptions<Services>
49+
): DelegatingKnowledgeOperation<O, Services> {
50+
if (
51+
options?.organizationDelegatedServices?.length &&
52+
(operation.minimumRole !== 'read' || options.organizationDelegation === 'deny')
53+
)
54+
throw new Error(`Operation ${operation.id} cannot delegate organization writes`)
2755
const supportsOrganizationDelegation =
2856
options?.organizationDelegation !== 'deny' &&
2957
operation.minimumRole === 'read' &&
30-
operation.delegatedServices?.includes('copilot')
58+
(operation.delegatedServices?.includes('copilot') ||
59+
Boolean(options?.organizationDelegatedServices?.length))
3160
const organizationOperation = defineOrganizationOperation({
3261
id: operation.id,
3362
capability: operation.capability,
@@ -44,11 +73,15 @@ function defineKnowledgeOperation<const O extends WorkspaceOperation>(
4473
],
4574
delegationAudience: 'sim:knowledge',
4675
delegatedServices:
47-
operation.id === 'knowledge.search' ? ['copilot', 'slack-search'] : ['copilot'],
76+
options?.organizationDelegatedServices ??
77+
(operation.id === 'knowledge.search' ? ['copilot', 'slack-search'] : ['copilot']),
4878
} as const)
4979
: ({ principalKinds: ['session', 'personal_api_key', 'oauth_access_token'] } as const)),
5080
})
51-
return Object.freeze({ ...operation, organizationOperation })
81+
return Object.freeze({ ...operation, organizationOperation }) as DelegatingKnowledgeOperation<
82+
O,
83+
Services
84+
>
5285
}
5386

5487
const ALL_PRINCIPAL_POLICY = {
@@ -681,7 +714,8 @@ export const knowledgeOperations = {
681714
workspaceApiKey: 'deny',
682715
capability: 'knowledge.use',
683716
principalKinds: ['session'],
684-
})
717+
}),
718+
{ organizationDelegatedServices: ['slack-search'] }
685719
),
686720
readSearchSourceOverview: defineKnowledgeOperation(
687721
defineWorkspaceOperation({

apps/sim/lib/knowledge/application/search-sources.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ import {
6868
import { readSearchSourceOverview } from '@/lib/knowledge/application/search-source-overview'
6969
import { readSearchSourceProgress } from '@/lib/knowledge/application/search-source-progress'
7070
import { listSearchSources } from '@/lib/knowledge/application/search-sources'
71+
import { slackSearchMemberPrincipal } from '@/lib/knowledge/application/slack-search/member-principal'
7172

7273
const principal = { kind: 'session' as const, userId: 'reader', sessionId: 'session' }
7374
const input = { workspaceId: 'workspace' }
@@ -371,6 +372,72 @@ describe('Search source summaries', () => {
371372
})
372373

373374
describe('organization Search source summaries', () => {
375+
function slackPrincipal() {
376+
return slackSearchMemberPrincipal(
377+
{ installationId: 'i1', message: { eventId: 'Ev1' } },
378+
'org-1',
379+
'reader'
380+
)
381+
}
382+
it('uses the Slack member’s ACL and includes their expired connection for Home', async () => {
383+
const delegated = slackPrincipal()
384+
mocks.context.mockResolvedValue({ organizationId: 'org-1' })
385+
queueTableRows(member, [{ role: 'member' }])
386+
mocks.memberships.mockResolvedValue(new Map([['drive', 'needs_reauth']]))
387+
seed([source('drive', 'google_drive', 'members')])
388+
const result = await listSearchSources.execute({
389+
principal: delegated,
390+
input: { organizationId: 'org-1' },
391+
})
392+
expect(result.sources[0]).toMatchObject({
393+
viewerMembership: 'needs_reauth',
394+
connectionRequired: true,
395+
})
396+
expect(mocks.access).toHaveBeenCalledWith(delegated, { organizationId: 'org-1' })
397+
expect(mocks.memberships).toHaveBeenCalledWith(
398+
expect.objectContaining({ userId: 'reader', organizationId: 'org-1' })
399+
)
400+
})
401+
it('refuses Slack delegation into another organization', async () => {
402+
mocks.context.mockResolvedValue({ organizationId: 'org-2' })
403+
await expect(
404+
listSearchSources.execute({ principal: slackPrincipal(), input: { organizationId: 'org-2' } })
405+
).rejects.toThrow('delegation')
406+
expect(mocks.memberships).not.toHaveBeenCalled()
407+
})
408+
it('rechecks Slack membership and delegation expiry before listing sources', async () => {
409+
mocks.context.mockResolvedValue({ organizationId: 'org-1' })
410+
queueTableRows(member, [])
411+
await expect(
412+
listSearchSources.execute({ principal: slackPrincipal(), input: { organizationId: 'org-1' } })
413+
).rejects.toThrow('Organization not found')
414+
await expect(
415+
listSearchSources.execute({
416+
principal: { ...slackPrincipal(), expiresAt: new Date(0) },
417+
input: { organizationId: 'org-1' },
418+
})
419+
).rejects.toThrow('delegation')
420+
expect(mocks.memberships).not.toHaveBeenCalled()
421+
})
422+
it('does not make the source list available to Copilot or Slack installation authority', async () => {
423+
await expect(
424+
listSearchSources.execute({
425+
principal: {
426+
kind: 'organization_delegated',
427+
serviceId: 'copilot',
428+
organizationId: 'org-1',
429+
subjectUserId: 'reader',
430+
delegationId: 'd1',
431+
audience: 'sim:knowledge',
432+
issuedAt: new Date(),
433+
expiresAt: new Date(Date.now() + 60_000),
434+
resourceScope: { chatId: 'chat1' },
435+
},
436+
input: { organizationId: 'org-1' },
437+
})
438+
).rejects.toThrow('delegation')
439+
expect(mocks.context).not.toHaveBeenCalled()
440+
})
374441
it.each(['member', 'admin'])(
375442
'returns only the current %s viewer ACL counts without a workspace membership',
376443
async (role) => {

apps/sim/lib/knowledge/application/search-sources.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
12
import { db } from '@sim/db'
23
import { document, embedding, knowledgeBase, knowledgeConnector, user } from '@sim/db/schema'
34
import { and, desc, eq, exists, inArray, isNull, lt, or, sql } from 'drizzle-orm'
@@ -37,12 +38,13 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({
3738
resolveContext: ({ input }: { input: ListSearchSourcesInput }) =>
3839
resolveKnowledgeOwnerContext(input),
3940
async execute({ principal, input, context }) {
41+
const userId = requirePrincipalSubjectUserId(principal)
4042
const search = input.search?.trim().toLowerCase() ?? ''
4143
const connectorType = input.connectorType?.trim()
4244
const cursorScope = cursorScopeKey(cursorRoute(listSearchSourcesContract), {
4345
workspaceId: context.workspaceId,
4446
organizationId: context.organizationId,
45-
userId: principal.userId,
47+
userId,
4648
search,
4749
connectorType: connectorType ?? '',
4850
mine: input.mine === true,
@@ -109,15 +111,15 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({
109111
const [availability, memberships, viewers, access, approvals] = await Promise.all([
110112
resolveKnowledgeAccessAvailability(context),
111113
resolveViewerConnectorMemberships({
112-
userId: principal.userId,
114+
userId,
113115
workspaceId: context.workspaceId,
114116
organizationId: context.organizationId,
115117
connectors: scanned,
116118
}),
117119
db
118120
.select({ emailVerified: user.emailVerified })
119121
.from(user)
120-
.where(eq(user.id, principal.userId))
122+
.where(eq(user.id, userId))
121123
.limit(1),
122124
createKnowledgeAccessProvider(principal, context).get(),
123125
context.organizationId ? listOrganizationSearchApprovals(context.organizationId) : null,

apps/sim/lib/knowledge/application/slack-search/assistant.ts

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
import type {
2-
OrganizationDelegatedPrincipal,
3-
SlackInstallationPrincipal,
4-
} from '@sim/auth/principal'
1+
import type { SlackInstallationPrincipal } from '@sim/auth/principal'
52
import { createLogger } from '@sim/logger'
63
import { toError } from '@sim/utils/errors'
74
import { generateId } from '@sim/utils/id'
@@ -41,6 +38,7 @@ import {
4138
resolveSlackSearchMember,
4239
SlackSearchIdentityError,
4340
} from '@/lib/knowledge/application/slack-search/identity'
41+
import { slackSearchMemberPrincipal } from '@/lib/knowledge/application/slack-search/member-principal'
4442
import { sendSlackSearchOnboarding } from '@/lib/knowledge/application/slack-search/onboarding'
4543
import { recordSlackSearchOutcome } from '@/lib/knowledge/application/slack-search/repository'
4644
import { getSlackSearchSourceStatus } from '@/lib/knowledge/application/slack-search/source-status'
@@ -60,26 +58,6 @@ import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secr
6058

6159
const logger = createLogger('SlackSearchAssistant')
6260

63-
/** Creates narrowly scoped, short-lived authority for the current verified Slack sender. */
64-
export function slackSearchMemberPrincipal(
65-
job: SlackSearchJob,
66-
organizationId: string,
67-
userId: string
68-
): OrganizationDelegatedPrincipal {
69-
const issuedAt = new Date()
70-
return {
71-
kind: 'organization_delegated',
72-
serviceId: 'slack-search',
73-
organizationId,
74-
subjectUserId: userId,
75-
delegationId: `${job.installationId}:${job.message.eventId}`,
76-
audience: 'sim:knowledge',
77-
issuedAt,
78-
expiresAt: new Date(issuedAt.getTime() + 60_000),
79-
resourceScope: { installationId: job.installationId, eventId: job.message.eventId },
80-
}
81-
}
82-
8361
/** Runs the product's organization Assistant with its ordinary tools, chat lock, billing, and persistence. */
8462
export async function runSlackSearchAssistant(
8563
principal: SlackInstallationPrincipal,

0 commit comments

Comments
 (0)