Skip to content

Commit ecdc10b

Browse files
committed
fix(slack): revoke shared grants on stale uninstall, pair app and workspace ids, audit text
1 parent daabd06 commit ecdc10b

6 files changed

Lines changed: 69 additions & 33 deletions

File tree

‎apps/sim/lib/api/contracts/organization-accounts.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@ export const startOrganizationSlackConfigurationContract = defineRouteContract({
133133
/** Omitting `appId` selects the shared Slack app for an organization with no installed bot. */
134134
body: startSlackCredentialGroupConfigurationBodySchema
135135
.omit({ slackBotCredentialId: true, clientId: true, clientSecret: true })
136-
.refine((body) => body.appId !== undefined || body.teamId === undefined, {
137-
message: 'Select the Slack app for this workspace',
136+
.refine((body) => (body.appId === undefined) === (body.teamId === undefined), {
137+
message: 'Select the Slack app and its workspace together',
138138
path: ['appId'],
139139
}),
140140
response: startSlackCredentialGroupConfigurationContract.response,

‎apps/sim/lib/credential-groups/provider-configuration.test.ts‎

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,9 @@ describe('organization Slack app references', () => {
7979
clientSecret: 'current-secret',
8080
})
8181
})
82-
it.each([
83-
[true, true],
84-
[false, true],
85-
[true, false],
86-
])(
87-
'resolves shared credentials only for an active installation and enabled organization (active=%s, enabled=%s)',
88-
async (active, enabled) => {
82+
it.each([true, false])(
83+
'resolves shared credentials without a Search bot only for an enabled organization (enabled=%s)',
84+
async (enabled) => {
8985
setEnv({ SLACK_SEARCH_APP_ID: 'A1' })
9086
shared.flag.mockImplementation(
9187
async (_flag, context) => enabled && context?.orgId === 'org-1'
@@ -107,20 +103,19 @@ describe('organization Slack app references', () => {
107103
encryptedSigningSecret: null,
108104
},
109105
])
110-
.mockResolvedValueOnce(active ? [{ id: 'installation' }] : [])
111106
const result = getSlackCredentialGroupConfiguration({
112107
organizationId: 'org-1',
113108
credentialGroupId: 'group-1',
114109
})
115-
if (!enabled) await expect(result).rejects.toThrow('unavailable')
116-
else if (active)
110+
if (enabled)
117111
await expect(result).resolves.toMatchObject({
118112
clientId: 'environment-client',
119113
clientSecret: 'environment-secret',
120114
appId: 'A1',
121115
teamId: 'T1',
122116
})
123-
else await expect(result).rejects.toThrow('disabled or removed')
117+
else await expect(result).rejects.toThrow('unavailable')
118+
expect(dbChainMockFns.limit).toHaveBeenCalledTimes(2)
124119
expect(shared.flag).toHaveBeenCalledWith('slack-search-shared-app', { orgId: 'org-1' })
125120
}
126121
)

‎apps/sim/lib/knowledge/application/search-integrations.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,8 @@ export const approveSearchIntegration = defineAuthorizedKnowledgeUseCase({
179179
resolveContext: ({ input }: { input: ApproveSearchIntegrationInput }) =>
180180
resolveKnowledgeOwnerContext({ organizationId: input.organizationId }),
181181
async execute({ input, context, principal }) {
182-
if (!context.organizationId)
183-
throw new OrchestrationError('validation', 'Organization is required')
182+
const { organizationId } = context
183+
if (!organizationId) throw new OrchestrationError('validation', 'Organization is required')
184184
const source = LIVE_SEARCH_SOURCE_TYPES.find(([type]) => type === input.connectorType)
185185
if (!source) {
186186
throw new OrchestrationError('validation', 'This integration is not supported by Sim Search')
@@ -276,7 +276,7 @@ export const approveSearchIntegration = defineAuthorizedKnowledgeUseCase({
276276
/** Slack sign-in belongs to Slack setup (bot install or admin verification), not approval. */
277277
if (memberProvider === 'slack') {
278278
const group = await ensureWorkspaceAccountsGroup(
279-
{ kind: 'organization', organizationId: context.organizationId! },
279+
{ kind: 'organization', organizationId },
280280
requirePrincipalSubjectUserId(principal),
281281
undefined,
282282
tx
@@ -333,7 +333,10 @@ export const approveSearchIntegration = defineAuthorizedKnowledgeUseCase({
333333
action: AuditAction.CREDENTIAL_GROUP_UPDATED,
334334
resourceType: AuditResourceType.CREDENTIAL_GROUP,
335335
resourceId: result.memberAccounts.groupId,
336-
description: `Configured ${result.connectorType} member sign-in for Sim Search`,
336+
description:
337+
result.connectorType === 'slack'
338+
? 'Created connected accounts for Slack Sim Search'
339+
: `Configured ${result.connectorType} member sign-in for Sim Search`,
337340
metadata: { connectorType: result.connectorType },
338341
},
339342
]

‎apps/sim/lib/knowledge/application/slack-search/lifecycle.test.ts‎

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,10 +181,7 @@ describe('Slack access revocation', () => {
181181
expect(m.app).not.toHaveBeenCalled()
182182
}
183183
)
184-
it.each([
185-
{ type: 'app_uninstalled' as const },
186-
{ type: 'tokens_revoked' as const, tokens: { bot: ['UBOT'] } },
187-
])('ignores stale installation-wide revocations before any writes: %j', async (event) => {
184+
const queueReinstalledBot = () => {
188185
resetDbChainMock()
189186
queueTableRows(slackSearchInstallation, [
190187
{
@@ -196,7 +193,42 @@ describe('Slack access revocation', () => {
196193
updatedAt: new Date(input.event_time * 1000 + 1000),
197194
},
198195
])
199-
await revokeSlackSearchAccess.execute({ principal, input: { ...input, event } })
196+
}
197+
it('ignores a stale bot revocation before any writes', async () => {
198+
queueReinstalledBot()
199+
await revokeSlackSearchAccess.execute({
200+
principal,
201+
input: { ...input, event: { type: 'tokens_revoked', tokens: { bot: ['UBOT'] } } },
202+
})
203+
expect(dbChainMockFns.update).not.toHaveBeenCalled()
204+
})
205+
it('keeps a reinstalled bot but revokes shared-app grants issued before a stale uninstall', async () => {
206+
queueReinstalledBot()
207+
await revokeSlackSearchAccess.execute({ principal, input })
208+
expect(dbChainMockFns.update.mock.calls.map(([table]) => table)).toEqual([credential])
209+
const memberGrants = dbChainMockFns.where.mock.calls[1][0]
210+
expect(memberGrants.conditions).toEqual(
211+
expect.arrayContaining([
212+
{ type: 'eq', left: credential.authorizationAppId, right: 'slack:A1:T1' },
213+
{
214+
type: 'or',
215+
conditions: [
216+
{ type: 'isNull', column: credential.grantedAt },
217+
{ type: 'lte', left: credential.grantedAt, right: new Date(input.event_time * 1000) },
218+
],
219+
},
220+
])
221+
)
222+
expect(memberGrants.conditions).not.toContainEqual(
223+
expect.objectContaining({ left: credential.organizationId })
224+
)
225+
})
226+
it('ignores a stale custom-app uninstall before any writes', async () => {
227+
m.app.mockResolvedValue({
228+
app: { id: 'A1', kind: 'custom', revision: 'r1', organizationId: 'org' },
229+
})
230+
queueReinstalledBot()
231+
await revokeSlackSearchAccess.execute({ principal, input })
200232
expect(dbChainMockFns.update).not.toHaveBeenCalled()
201233
})
202234
it.each([{ oauth: ['U1'] }, { oauth: ['U1'], bot: ['UBOT'] }])(

‎apps/sim/lib/knowledge/application/slack-search/lifecycle.ts‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { and, eq, inArray, isNull, lte, or, sql } from 'drizzle-orm'
55
import { z } from 'zod'
66
import type { OperationUseCase } from '@/lib/core/application/operation'
77
import { OrchestrationError } from '@/lib/core/orchestration/types'
8+
import type { DbTransaction } from '@/lib/db/types'
89
import { loadSlackAppConfiguration } from '@/lib/slack-search/app-configuration'
910

1011
const id = z.string().min(1).max(200)
@@ -65,6 +66,11 @@ export const revokeSlackSearchAccess: OperationUseCase<
6566
or(isNull(credential.grantedAt), lte(credential.grantedAt, occurredAt)),
6667
...(uninstall ? [] : [inArray(credential.providerSubjectId, revokedUsers)])
6768
)
69+
const revokeMemberGrants = (tx: DbTransaction, organizationId?: string) =>
70+
tx
71+
.update(credential)
72+
.set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() })
73+
.where(memberGrants(organizationId))
6874
await db.transaction(async (tx) => {
6975
const [installation] = await tx
7076
.select()
@@ -79,28 +85,28 @@ export const revokeSlackSearchAccess: OperationUseCase<
7985
.limit(1)
8086
/** Member sign-in through the shared app needs no Search bot, so it has no installation. */
8187
if (!installation) {
82-
if (sharedApp && (uninstall || revokedUsers.length))
83-
await tx
84-
.update(credential)
85-
.set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() })
86-
.where(memberGrants())
88+
if (sharedApp && (uninstall || revokedUsers.length)) await revokeMemberGrants(tx)
8789
return
8890
}
8991
if (app.app.kind === 'custom' && app.app.organizationId !== installation.organizationId)
9092
throw new Error('Slack installation ownership is inconsistent')
9193
const staleInstallation = installation.updatedAt > occurredAt
92-
if (uninstall && staleInstallation) return
94+
if (uninstall && staleInstallation) {
95+
/** A reinstall keeps its bot, but shared-app grants issued before the uninstall are gone. */
96+
if (sharedApp) await revokeMemberGrants(tx)
97+
return
98+
}
9399
const revokeBot =
94100
!staleInstallation &&
95101
(uninstall ||
96102
(input.event.type === 'tokens_revoked' &&
97103
(input.event.tokens.bot ?? []).includes(installation.botUserId)))
98104
let revokedMemberIds: string[] = []
99105
if (uninstall || revokedUsers.length) {
100-
const revokeCredentials = tx
101-
.update(credential)
102-
.set({ managedOauthStatus: 'needs_reauth', updatedAt: new Date() })
103-
.where(memberGrants(sharedApp ? undefined : installation.organizationId))
106+
const revokeCredentials = revokeMemberGrants(
107+
tx,
108+
sharedApp ? undefined : installation.organizationId
109+
)
104110
if (revokeBot) await revokeCredentials
105111
else {
106112
const revokedCredentials = await revokeCredentials.returning({

‎scripts/check-explicit-any.baseline.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1422,7 +1422,7 @@
14221422
"apps/sim/lib/data-drains/sources/copilot-runs.ts": 2,
14231423
"apps/sim/lib/data-drains/sources/job-logs.ts": 2,
14241424
"apps/sim/lib/data-drains/sources/workflow-logs.ts": 2,
1425-
"apps/sim/lib/desktop/source-browser.ts": 3,
1425+
"apps/sim/lib/desktop/source-browser.ts": 1,
14261426
"apps/sim/lib/desktop/source-connect.ts": 1,
14271427
"apps/sim/lib/desktop/source-request.integration.ts": 1,
14281428
"apps/sim/lib/embeddings/client.ts": 4,

0 commit comments

Comments
 (0)