Skip to content

Commit 51ec31b

Browse files
improvement(slack): simplify Home to a persistent connect link
1 parent 451258c commit 51ec31b

11 files changed

Lines changed: 181 additions & 549 deletions

File tree

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

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

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

2930
function requireKnowledgePrincipal<O extends ScopedKnowledgeOperation>(
3031
principal: Principal,

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

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,11 @@ 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 search and the personalized source list', () => {
10+
it('limits Slack member delegation to the existing search operation', () => {
1111
const allowed = Object.values(knowledgeOperations).filter((operation) =>
1212
operation.organizationOperation.delegatedServices?.includes('slack-search')
1313
)
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'])
14+
expect(allowed).toEqual([knowledgeOperations.search])
1915
})
2016
it('defines unique stable semantic operation IDs', () => {
2117
const ids = Object.values(knowledgeOperations).map((operation) => operation.id)
@@ -148,13 +144,12 @@ describe('knowledge operation registry', () => {
148144
}
149145
})
150146

151-
it('permits organization delegation only for explicitly delegated reads', () => {
147+
it('permits organization delegation only for Copilot reads', () => {
152148
for (const operation of Object.values(knowledgeOperations)) {
153149
if (!operation.organizationOperation.principalKinds.includes('organization_delegated'))
154150
continue
155151
expect(operation.minimumRole).toBe('read')
156-
if (operation !== knowledgeOperations.listSearchSources)
157-
expect(operation.delegatedServices).toContain('copilot')
152+
expect(operation.delegatedServices).toContain('copilot')
158153
expect(operation.organizationOperation.delegationAudience).toBe('sim:knowledge')
159154
}
160155
expect(knowledgeOperations.search.organizationOperation.principalKinds).toContain(

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

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

19-
interface KnowledgeOperationOptions<
20-
Services extends readonly OrganizationDelegatedPrincipal['serviceId'][],
21-
> {
18+
interface KnowledgeOperationOptions {
2219
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-
}
4020
}
4121

4222
/** Binds organization policy to the same semantic operation declared for workspace access. */
43-
function defineKnowledgeOperation<
44-
const O extends WorkspaceOperation,
45-
const Services extends readonly OrganizationDelegatedPrincipal['serviceId'][] = readonly [],
46-
>(
23+
function defineKnowledgeOperation<const O extends WorkspaceOperation>(
4724
operation: 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`)
25+
options?: KnowledgeOperationOptions
26+
): ScopedKnowledgeOperation<O> {
5527
const supportsOrganizationDelegation =
5628
options?.organizationDelegation !== 'deny' &&
5729
operation.minimumRole === 'read' &&
58-
(operation.delegatedServices?.includes('copilot') ||
59-
Boolean(options?.organizationDelegatedServices?.length))
30+
operation.delegatedServices?.includes('copilot')
6031
const organizationOperation = defineOrganizationOperation({
6132
id: operation.id,
6233
capability: operation.capability,
@@ -73,15 +44,11 @@ function defineKnowledgeOperation<
7344
],
7445
delegationAudience: 'sim:knowledge',
7546
delegatedServices:
76-
options?.organizationDelegatedServices ??
77-
(operation.id === 'knowledge.search' ? ['copilot', 'slack-search'] : ['copilot']),
47+
operation.id === 'knowledge.search' ? ['copilot', 'slack-search'] : ['copilot'],
7848
} as const)
7949
: ({ principalKinds: ['session', 'personal_api_key', 'oauth_access_token'] } as const)),
8050
})
81-
return Object.freeze({ ...operation, organizationOperation }) as DelegatingKnowledgeOperation<
82-
O,
83-
Services
84-
>
51+
return Object.freeze({ ...operation, organizationOperation })
8552
}
8653

8754
const ALL_PRINCIPAL_POLICY = {
@@ -714,8 +681,7 @@ export const knowledgeOperations = {
714681
workspaceApiKey: 'deny',
715682
capability: 'knowledge.use',
716683
principalKinds: ['session'],
717-
}),
718-
{ organizationDelegatedServices: ['slack-search'] }
684+
})
719685
),
720686
readSearchSourceOverview: defineKnowledgeOperation(
721687
defineWorkspaceOperation({

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

Lines changed: 0 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,6 @@ 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'
7271

7372
const principal = { kind: 'session' as const, userId: 'reader', sessionId: 'session' }
7473
const input = { workspaceId: 'workspace' }
@@ -372,72 +371,6 @@ describe('Search source summaries', () => {
372371
})
373372

374373
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-
})
441374
it.each(['member', 'admin'])(
442375
'returns only the current %s viewer ACL counts without a workspace membership',
443376
async (role) => {

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

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { requirePrincipalSubjectUserId } from '@sim/auth/principal'
21
import { db } from '@sim/db'
32
import { document, embedding, knowledgeBase, knowledgeConnector, user } from '@sim/db/schema'
43
import { and, desc, eq, exists, inArray, isNull, lt, or, sql } from 'drizzle-orm'
@@ -38,13 +37,12 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({
3837
resolveContext: ({ input }: { input: ListSearchSourcesInput }) =>
3938
resolveKnowledgeOwnerContext(input),
4039
async execute({ principal, input, context }) {
41-
const userId = requirePrincipalSubjectUserId(principal)
4240
const search = input.search?.trim().toLowerCase() ?? ''
4341
const connectorType = input.connectorType?.trim()
4442
const cursorScope = cursorScopeKey(cursorRoute(listSearchSourcesContract), {
4543
workspaceId: context.workspaceId,
4644
organizationId: context.organizationId,
47-
userId,
45+
userId: principal.userId,
4846
search,
4947
connectorType: connectorType ?? '',
5048
mine: input.mine === true,
@@ -111,15 +109,15 @@ export const listSearchSources = defineAuthorizedKnowledgeUseCase({
111109
const [availability, memberships, viewers, access, approvals] = await Promise.all([
112110
resolveKnowledgeAccessAvailability(context),
113111
resolveViewerConnectorMemberships({
114-
userId,
112+
userId: principal.userId,
115113
workspaceId: context.workspaceId,
116114
organizationId: context.organizationId,
117115
connectors: scanned,
118116
}),
119117
db
120118
.select({ emailVerified: user.emailVerified })
121119
.from(user)
122-
.where(eq(user.id, userId))
120+
.where(eq(user.id, principal.userId))
123121
.limit(1),
124122
createKnowledgeAccessProvider(principal, context).get(),
125123
context.organizationId ? listOrganizationSearchApprovals(context.organizationId) : null,

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import type { SlackInstallationPrincipal } from '@sim/auth/principal'
1+
import type {
2+
OrganizationDelegatedPrincipal,
3+
SlackInstallationPrincipal,
4+
} from '@sim/auth/principal'
25
import { createLogger } from '@sim/logger'
36
import { toError } from '@sim/utils/errors'
47
import { generateId } from '@sim/utils/id'
@@ -38,7 +41,6 @@ import {
3841
resolveSlackSearchMember,
3942
SlackSearchIdentityError,
4043
} from '@/lib/knowledge/application/slack-search/identity'
41-
import { slackSearchMemberPrincipal } from '@/lib/knowledge/application/slack-search/member-principal'
4244
import { sendSlackSearchOnboarding } from '@/lib/knowledge/application/slack-search/onboarding'
4345
import { recordSlackSearchOutcome } from '@/lib/knowledge/application/slack-search/repository'
4446
import { getSlackSearchSourceStatus } from '@/lib/knowledge/application/slack-search/source-status'
@@ -58,6 +60,26 @@ import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secr
5860

5961
const logger = createLogger('SlackSearchAssistant')
6062

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+
6183
/** Runs the product's organization Assistant with its ordinary tools, chat lock, billing, and persistence. */
6284
export async function runSlackSearchAssistant(
6385
principal: SlackInstallationPrincipal,

0 commit comments

Comments
 (0)