Skip to content

Commit af4c7bf

Browse files
committed
fix(accounts): preserve outbound ownership during reconnect
1 parent 3512ce6 commit af4c7bf

2 files changed

Lines changed: 79 additions & 22 deletions

File tree

‎apps/sim/lib/credential-groups/__integration__/organization-account-outbound.integration.ts‎

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
import { createHash } from 'node:crypto'
22
import { createServer } from 'node:http'
33
import { db } from '@sim/db'
4-
import { member, organization, user } from '@sim/db/schema'
4+
import { credential, credentialGroupEnrollment, member, organization, user } from '@sim/db/schema'
55
import { readTestRedisUrl } from '@sim/db/testing/test-infrastructure'
66
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
77
import { generateId } from '@sim/utils/id'
88
import { toRecord } from '@sim/utils/object'
9-
import { inArray } from 'drizzle-orm'
9+
import { eq, inArray } from 'drizzle-orm'
1010
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
1111
import { env } from '@/lib/core/config/env'
1212
import {
1313
resolveCurrentOutboundRoute,
1414
runWithOutboundOrganization,
1515
} from '@/lib/core/network/context.server'
1616
import { startOrganizationAccountConnection } from '@/lib/credential-groups/application/organization-accounts'
17+
import { reconnectPersonalOrganizationAccount } from '@/lib/credential-groups/application/personal-organization-accounts'
1718
import { createManagedMcpConnector } from '@/lib/credential-groups/managed-mcp-service'
1819
import { consumeCredentialGroupMcpOAuthAttempt } from '@/lib/credential-groups/mcp-oauth-state'
20+
import { createViewerCredentialGroupEnrollment } from '@/lib/credential-groups/self-enrollment'
1921
import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service'
22+
import { encryptManagedMcpTokens } from '@/lib/credentials/managed-mcp'
2023
import * as oauth from '@/lib/mcp/oauth/auth'
2124
import { createSsrfGuardedMcpFetch } from '@/lib/mcp/pinned-fetch'
2225

@@ -28,6 +31,7 @@ const outsider = generateId()
2831
const directOrg = generateId()
2932
const blockedOrg = generateId()
3033
const servers = new Map<string, string>()
34+
const grants = new Map<string, { credentialId: string; enrollmentId: string }>()
3135
const requests: string[] = []
3236

3337
/** Real OAuth discovery and registration over a socket; only the remote destination is replaced. */
@@ -122,6 +126,28 @@ beforeAll(async () => {
122126
)
123127
)
124128
servers.set(organizationId, mcpServer.id)
129+
const { enrollment } = await createViewerCredentialGroupEnrollment({
130+
organizationId,
131+
credentialGroupId: group.id,
132+
userId,
133+
})
134+
const credentialId = `mcp-cg-${generateId()}`
135+
await db.insert(credential).values({
136+
id: credentialId,
137+
organizationId,
138+
type: 'managed_mcp',
139+
displayName: 'OAuth fixture',
140+
grantedAt: new Date(),
141+
credentialGroupEnrollmentId: enrollment.id,
142+
mcpServerId: mcpServer.id,
143+
managedOauthStatus: 'active',
144+
mcpTools: [],
145+
encryptedOauthTokenSet: await encryptManagedMcpTokens({
146+
access_token: 'fixture-access',
147+
token_type: 'Bearer',
148+
}),
149+
})
150+
grants.set(organizationId, { credentialId, enrollmentId: enrollment.id })
125151
}
126152
await new Promise<void>((resolve) => providerServer.listen(0, '127.0.0.1', resolve))
127153
const address = providerServer.address()
@@ -169,8 +195,19 @@ function connect(
169195
})
170196
}
171197

172-
async function verifyAuthorization() {
173-
const result = await connect(directOrg)
198+
function reconnect(organizationId: string, userId = owner) {
199+
return reconnectPersonalOrganizationAccount.execute({
200+
principal: createSessionPrincipal({ userId, sessionId: generateId() }),
201+
input: {
202+
credentialId: grants.get(organizationId)!.credentialId,
203+
oauthCompletionId: generateId(),
204+
},
205+
})
206+
}
207+
208+
async function verifyAuthorization(start: typeof connect | typeof reconnect) {
209+
const result = await start(directOrg)
210+
if (!result.authorizationUrl) throw new Error('OAuth authorization URL is missing')
174211
const authorization = new URL(result.authorizationUrl)
175212
expect(`${authorization.origin}${authorization.pathname}`).toBe(`${ISSUER}/authorize`)
176213
expect(authorization.searchParams.get('client_id')).toBe('fixture-dynamic-client')
@@ -189,27 +226,33 @@ async function verifyAuthorization() {
189226
expect(await consumeCredentialGroupMcpOAuthAttempt(state)).toBeNull()
190227
}
191228

192-
describe('Organization account OAuth outbound ownership', () => {
229+
describe.each([
230+
{ name: 'Connect', start: connect },
231+
{ name: 'Reconnect', start: reconnect },
232+
])('$name account OAuth outbound ownership', ({ start }) => {
193233
it('starts dynamic OAuth with a bound single-use attempt when no ambient scope exists', async () => {
194-
await verifyAuthorization()
234+
await verifyAuthorization(start)
195235
await expect(resolveCurrentOutboundRoute()).rejects.toMatchObject({ code: 'MISSING_SCOPE' })
196236
})
197237
it('uses authorized ownership instead of an ambient blocked organization and restores the outer scope', async () => {
198238
await runWithOutboundOrganization(blockedOrg, async () => {
199-
await verifyAuthorization()
239+
await verifyAuthorization(start)
200240
await expect(resolveCurrentOutboundRoute()).rejects.toMatchObject({ code: 'ROUTE_BLOCKED' })
201241
})
202242
})
203243
it('does not bypass an organization block through ambient platform scope', async () => {
204244
const before = requests.length
205245
await runWithOutboundOrganization(null, async () => {
206-
await expect(connect(blockedOrg, blockedOwner)).rejects.toMatchObject({
246+
await expect(start(blockedOrg, blockedOwner)).rejects.toMatchObject({
207247
code: 'ROUTE_BLOCKED',
208248
})
209249
expect(await resolveCurrentOutboundRoute()).toEqual({ kind: 'direct' })
210250
})
211251
expect(requests.length).toBe(before)
212252
})
253+
})
254+
255+
describe('Account authorization before outbound OAuth', () => {
213256
it('denies nonmembers and cross-organization providers before contacting OAuth', async () => {
214257
const before = requests.length
215258
await expect(connect(directOrg, outsider)).rejects.toMatchObject({ code: 'not_found' })
@@ -218,4 +261,15 @@ describe('Organization account OAuth outbound ownership', () => {
218261
})
219262
expect(requests.length).toBe(before)
220263
})
264+
it('denies another contributor and a revoked enrollment during reconnect', async () => {
265+
const before = requests.length
266+
await expect(reconnect(directOrg, outsider)).rejects.toMatchObject({ code: 'not_found' })
267+
const enrollmentId = grants.get(directOrg)!.enrollmentId
268+
await db
269+
.update(credentialGroupEnrollment)
270+
.set({ status: 'revoked' })
271+
.where(eq(credentialGroupEnrollment.id, enrollmentId))
272+
await expect(reconnect(directOrg)).rejects.toMatchObject({ code: 'forbidden' })
273+
expect(requests.length).toBe(before)
274+
})
221275
})

‎apps/sim/lib/credential-groups/application/personal-organization-accounts.ts‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { sha256Hex } from '@sim/security/hash'
1212
import { generateId } from '@sim/utils/id'
1313
import { and, asc, eq, gt, inArray, isNotNull } from 'drizzle-orm'
1414
import { defineOperation } from '@/lib/core/application'
15+
import { runWithOutboundOrganization } from '@/lib/core/network/context.server'
1516
import { OrchestrationError } from '@/lib/core/orchestration/types'
1617
import { sameResourceScopeCondition } from '@/lib/core/resource-scope.server'
1718
import { createCredentialGroupOAuthStartUrl } from '@/lib/credential-groups/enrollment-links'
@@ -156,20 +157,22 @@ export const reconnectPersonalOrganizationAccount = defineAuthorizedCredentialUs
156157
completionId: input.oauthCompletionId,
157158
returnTo: 'integrations' as const,
158159
}
159-
if (account.type === 'managed_oauth' && account.optionId) {
160-
return startViewerCredentialGroupOAuth({
161-
...connection,
162-
optionId: account.optionId,
163-
connectionIntent: { kind: 'reconnect', credentialId: account.credentialId },
164-
})
165-
}
166-
if (account.type === 'managed_mcp' && account.mcpServerId) {
167-
return startViewerCredentialGroupMcpOAuth({
168-
...connection,
169-
mcpServerId: account.mcpServerId,
170-
})
171-
}
172-
throw new OrchestrationError('not_found', 'This account provider is no longer available')
160+
return runWithOutboundOrganization(account.organizationId, () => {
161+
if (account.type === 'managed_oauth' && account.optionId) {
162+
return startViewerCredentialGroupOAuth({
163+
...connection,
164+
optionId: account.optionId,
165+
connectionIntent: { kind: 'reconnect', credentialId: account.credentialId },
166+
})
167+
}
168+
if (account.type === 'managed_mcp' && account.mcpServerId) {
169+
return startViewerCredentialGroupMcpOAuth({
170+
...connection,
171+
mcpServerId: account.mcpServerId,
172+
})
173+
}
174+
throw new OrchestrationError('not_found', 'This account provider is no longer available')
175+
})
173176
}
174177
const { invitationLink } = await createViewerCredentialGroupEnrollment({
175178
organizationId: account.organizationId,

0 commit comments

Comments
 (0)