Skip to content

Commit dbdfbf2

Browse files
committed
fix(search): apply shared personal account limits and eligibility
1 parent 84c8dd2 commit dbdfbf2

14 files changed

Lines changed: 208 additions & 57 deletions

File tree

‎apps/docs/content/docs/search/flow.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ Flow Engineering Search retrieves current engineering items when you search. It
1313

1414
The default API host is `backend.branch.flowengineering.com`; use the host shown by your Flow instance. Sim saves the destination with the credential and verifies the current user before each Search operation. Other members cannot search with your connection. Reconnecting preserves the provider identity and workspace; use a new connection for a different account or workspace.
1515

16+
You can connect up to five accounts per provider. Reconnect an existing account to rotate its credentials.
17+
1618
## Search and read
1719

1820
Use a short phrase, optionally scoped to a known Flow project ID. Without a project, Sim searches at most six accessible projects. Each project returns up to 100 matches, subject to the requested result limit. Flow does not provide a continuation cursor for this search endpoint, so narrow the project or query when results are incomplete.

‎apps/docs/content/docs/search/jama.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ Sim exchanges the saved client credentials for a short-lived access token. Searc
1515

1616
Self-hosted instance URLs may include a deployment context path. The instance must be reachable from Sim's server environment. If your organization uses dedicated outbound IPs, allowlist those addresses for HTTPS access.
1717

18+
You can connect up to five accounts per provider. Reconnect an existing account to rotate its credentials.
19+
1820
## Search and read
1921

2022
Search with a short phrase or distinctive requirement text. A native query can specify a numeric project ID. Each call retrieves up to 50 items; continue with the returned cursor to request another page.

‎apps/sim/app/api/search/personal-credentials/route.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@ import {
77
internalRateLimits,
88
internalSessionAuth,
99
} from '@/lib/api/server/routes'
10-
import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies'
10+
import {
11+
internalCredentialErrorPolicy,
12+
internalPersonalCredentialConnectionErrorPolicy,
13+
} from '@/lib/credentials/api/route-policies'
1114
import {
1215
removeSearchPersonalCredential,
1316
saveSearchPersonalCredential,
@@ -18,7 +21,7 @@ export const POST = defineInternalJsonRoute({
1821
auth: internalSessionAuth,
1922
operation: saveSearchPersonalCredential.operation,
2023
rateLimit: internalRateLimits.user({ bucketName: 'search-personal-credentials' }),
21-
errorPolicy: internalCredentialErrorPolicy,
24+
errorPolicy: internalPersonalCredentialConnectionErrorPolicy,
2225
parseOptions: { maxBodyBytes: 16_384 },
2326
mapInput: ({ body }) => body,
2427
useCase: saveSearchPersonalCredential,

‎apps/sim/lib/credential-groups/member-integrations.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,30 @@ function entryFor(provider: string, viewerAccounts: unknown[], viewerMcpAccounts
5656
}
5757

5858
describe('offering another account', () => {
59+
it.each(['jama', 'flow'] as const)(
60+
'keeps %s reconnects available but stops offering another account at the limit',
61+
(provider) => {
62+
const providerId = `${provider}-service-account` as const
63+
const accounts = Array.from({ length: 5 }, (_, index) => ({
64+
credentialId: `${provider}-${index}`,
65+
displayName: `account-${index}@example.test`,
66+
providerId,
67+
status: 'active' as const,
68+
}))
69+
const project = (viewerSavedAccounts: typeof accounts) =>
70+
memberIntegrationEntries({ ...settings([]), viewerSavedAccounts }, [
71+
approved(provider),
72+
]).find((entry) => entry.provider === provider)
73+
74+
expect(project(accounts.slice(0, 4))?.connectionTarget).toEqual({
75+
savedProviderId: providerId,
76+
})
77+
const full = project(accounts)
78+
expect(full?.connectionTarget).toBeUndefined()
79+
expect(full?.reconnectAccountIds).toEqual(accounts.map((entry) => entry.credentialId))
80+
}
81+
)
82+
5983
it('still offers the provider to a member who has connected one account', () => {
6084
expect(entryFor('google_drive', [account(1, 'drive', 'google-drive')])).toMatchObject({
6185
connectionTarget: { optionId: 'drive' },

‎apps/sim/lib/credential-groups/member-integrations.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,10 @@ export function memberIntegrationEntries(
121121
ready,
122122
permissionAccountIds: [],
123123
reconnectAccountIds: ready ? savedAccounts.map((account) => account.credentialId) : [],
124-
connectionTarget: ready ? { savedProviderId } : undefined,
124+
connectionTarget:
125+
ready && savedAccounts.length < CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT
126+
? { savedProviderId }
127+
: undefined,
125128
description: !policies
126129
? undefined
127130
: approval?.available === false

‎apps/sim/lib/credentials/application/search-personal-credentials.integration.ts‎

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import {
99
} from '@sim/db/schema'
1010
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
1111
import { createDeferred } from '@sim/testing/helpers/deferred'
12+
import {
13+
credentialGroupsAvailabilityMock,
14+
credentialGroupsAvailabilityMockFns,
15+
} from '@sim/testing/mocks/credential-groups-availability.mock'
1216
import { generateId } from '@sim/utils/id'
1317
import { and, eq, inArray } from 'drizzle-orm'
1418
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -20,6 +24,7 @@ import {
2024
import type { ServiceAccountSecretResult } from '@/lib/credentials/service-account-secret'
2125

2226
const verifier = vi.hoisted(() => vi.fn())
27+
vi.mock('@/lib/credential-groups/scoped-availability', () => credentialGroupsAvailabilityMock)
2328
vi.mock('@/lib/credentials/service-account-secret', () => ({
2429
verifyAndBuildServiceAccountSecret: verifier,
2530
}))
@@ -81,6 +86,7 @@ describe('personal Search connections in PostgreSQL', () => {
8186
})
8287

8388
beforeEach(async () => {
89+
credentialGroupsAvailabilityMockFns.mockIsScopedCredentialGroupsAvailable.mockReset()
8490
verifier.mockReset().mockResolvedValue(verified())
8591
await db
8692
.delete(credential)
@@ -120,6 +126,27 @@ describe('personal Search connections in PostgreSQL', () => {
120126
await db.delete(user).where(inArray(user.id, [userId, adminId]))
121127
})
122128

129+
it('refuses provider verification and persistence when connected accounts are unavailable', async () => {
130+
credentialGroupsAvailabilityMockFns.mockIsScopedCredentialGroupsAvailable.mockResolvedValueOnce(
131+
false
132+
)
133+
await expect(save()).rejects.toMatchObject({ code: 'not_found' })
134+
expect(verifier).not.toHaveBeenCalled()
135+
expect(await ownRows()).toEqual([])
136+
})
137+
138+
it('still removes a personal connection after connected accounts become unavailable', async () => {
139+
const created = await save()
140+
credentialGroupsAvailabilityMockFns.mockIsScopedCredentialGroupsAvailable.mockResolvedValue(
141+
false
142+
)
143+
await removeSearchPersonalCredential.execute({
144+
principal,
145+
input: { organizationId, ...created },
146+
})
147+
expect(await ownRows()).toEqual([])
148+
})
149+
123150
it('creates a private connection, rotates its secret, and removes its membership with it', async () => {
124151
const created = await save()
125152
expect(await ownRows()).toMatchObject([
@@ -234,7 +261,7 @@ describe('personal Search connections in PostgreSQL', () => {
234261
...created,
235262
...(changed === 'tenant' ? { orgId: 'https://other.example.test/jama' } : {}),
236263
})
237-
).rejects.toMatchObject({ code: 'validation' })
264+
).rejects.toMatchObject({ failure: 'account_mismatch', statusCode: 409 })
238265
expect(await ownRows()).toMatchObject([
239266
{ providerSubjectId: '42', providerTenantId: input.orgId },
240267
])
@@ -252,10 +279,10 @@ describe('personal Search connections in PostgreSQL', () => {
252279
const created = await save(flow)
253280
await expect(
254281
save({ ...flow, ...created, providerWorkspaceId: 'workspace-b' })
255-
).rejects.toMatchObject({ code: 'validation' })
282+
).rejects.toMatchObject({ failure: 'account_mismatch', statusCode: 409 })
256283
await expect(
257284
save({ ...flow, ...created, domain: 'https://other-flow.example.test' })
258-
).rejects.toMatchObject({ code: 'validation' })
285+
).rejects.toMatchObject({ failure: 'account_mismatch', statusCode: 409 })
259286
expect(await ownRows()).toMatchObject([
260287
{ providerTenantId: '["https://flow.example.test","workspace-a"]' },
261288
])
@@ -294,16 +321,58 @@ describe('personal Search connections in PostgreSQL', () => {
294321
id: generateId(),
295322
organizationId,
296323
createdBy: userId,
297-
providerId: 'jama-service-account',
324+
providerId: 'coda-service-account',
298325
displayName: 'Limit fixture',
299326
type: 'service_account' as const,
300327
}))
301328
)
329+
verifier.mockResolvedValueOnce(
330+
verified({ principal: { kind: 'user', id: '43', label: email } })
331+
)
302332
const results = await Promise.allSettled([save(), save()])
303333
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
304334
expect(results.filter((result) => result.status === 'rejected')).toMatchObject([
305335
{ reason: { code: 'validation' } },
306336
])
307337
expect(await ownRows()).toHaveLength(100)
308338
})
339+
340+
it('allows only one concurrent connection for the same verified provider identity', async () => {
341+
const results = await Promise.allSettled([save(), save()])
342+
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
343+
expect(results.filter((result) => result.status === 'rejected')).toMatchObject([
344+
{ reason: { failure: 'account_already_connected', statusCode: 409 } },
345+
])
346+
expect(await ownRows()).toHaveLength(1)
347+
})
348+
349+
it('limits concurrent additions per provider while allowing reconnect at the limit', async () => {
350+
const ids: string[] = []
351+
for (let index = 0; index < 4; index++) {
352+
verifier.mockResolvedValueOnce(
353+
verified({ principal: { kind: 'user', id: `member-${index}`, label: email } })
354+
)
355+
ids.push((await save()).credentialId)
356+
}
357+
verifier.mockResolvedValueOnce(
358+
verified({ principal: { kind: 'user', id: '43', label: email } })
359+
)
360+
const results = await Promise.allSettled([save(), save()])
361+
expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1)
362+
expect(results.filter((result) => result.status === 'rejected')).toMatchObject([
363+
{ reason: { failure: 'account_limit_reached', statusCode: 409 } },
364+
])
365+
verifier.mockResolvedValueOnce(
366+
verified({
367+
principal: { kind: 'user', id: 'member-0', label: email },
368+
encryptedServiceAccountKey: 'rotated-at-limit',
369+
})
370+
)
371+
await save({ credentialId: ids[0] })
372+
const rows = await ownRows()
373+
expect(rows).toHaveLength(5)
374+
expect(rows.find((row) => row.id === ids[0])?.encryptedServiceAccountKey).toBe(
375+
'rotated-at-limit'
376+
)
377+
})
309378
})

‎apps/sim/lib/credentials/application/search-personal-credentials.ts‎

Lines changed: 61 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,12 @@ import { authorizeOrganizationOperation } from '@/lib/core/application/organizat
1111
import { defineOrganizationOperation } from '@/lib/core/application/organization-operation'
1212
import { withResourceOutboundScope } from '@/lib/core/network/resource-scope.server'
1313
import { OrchestrationError } from '@/lib/core/orchestration/types'
14-
import { ORGANIZATION_VIEWER_ACCOUNT_LIMIT } from '@/lib/credential-groups/limits'
14+
import { resolveAccountWrite } from '@/lib/credential-groups/account-rules'
15+
import {
16+
CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT,
17+
ORGANIZATION_VIEWER_ACCOUNT_LIMIT,
18+
} from '@/lib/credential-groups/limits'
19+
import { isScopedCredentialGroupsAvailable } from '@/lib/credential-groups/scoped-availability'
1520
import { CredentialProviderOperationError } from '@/lib/credentials/application/crud'
1621
import {
1722
listCredentialProviderCatalog,
@@ -63,6 +68,14 @@ export const saveSearchPersonalCredential: OperationUseCase<
6368
operation: searchPersonalCredentialOperations.save,
6469
async execute({ principal, input, request }) {
6570
const context = await authorizeOrganizationOperation(principal, this.operation, input)
71+
if (
72+
!(await isScopedCredentialGroupsAvailable({
73+
kind: 'organization',
74+
organizationId: context.organizationId,
75+
}))
76+
) {
77+
throw new OrchestrationError('not_found', 'Connected accounts are not available')
78+
}
6679
const provider = input.providerId === 'jama-service-account' ? 'jama' : 'flow'
6780
await requireOrganizationSearchApproval(context.organizationId, provider)
6881
if (
@@ -134,6 +147,7 @@ export const saveSearchPersonalCredential: OperationUseCase<
134147
.limit(1)
135148
if (
136149
!person?.verified ||
150+
verified.principal?.kind !== 'user' ||
137151
!providerEmail ||
138152
providerEmail !== person.email.trim().toLowerCase()
139153
) {
@@ -142,53 +156,44 @@ export const saveSearchPersonalCredential: OperationUseCase<
142156
'Use your own provider account with the same email as your verified Sim account'
143157
)
144158
}
145-
if (input.credentialId) {
146-
const [existing] = await tx
147-
.select({
148-
tenantId: credential.providerTenantId,
149-
subjectId: credential.providerSubjectId,
150-
})
151-
.from(credential)
152-
.where(ownedCredential(context.organizationId, context.userId, input.credentialId))
153-
.for('update')
154-
.limit(1)
155-
if (
156-
!existing ||
157-
existing.tenantId !== tenantId ||
158-
verified.principal?.kind !== 'user' ||
159-
existing.subjectId !== verified.principal.id
160-
) {
161-
throw new OrchestrationError(
162-
'validation',
163-
'Reconnect with the same provider account and workspace. Add a new connection to use a different account.'
159+
const accounts = await tx
160+
.select({
161+
id: credential.id,
162+
revokedAt: credential.revokedAt,
163+
providerSubjectId: credential.providerSubjectId,
164+
providerTenantId: credential.providerTenantId,
165+
})
166+
.from(credential)
167+
.where(
168+
and(
169+
eq(credential.organizationId, context.organizationId),
170+
isNull(credential.workspaceId),
171+
eq(credential.createdBy, context.userId),
172+
eq(credential.type, 'service_account'),
173+
eq(credential.providerId, input.providerId)
164174
)
165-
}
166-
}
175+
)
176+
.for('update')
177+
const existing = resolveAccountWrite({
178+
intent: input.credentialId
179+
? { kind: 'reconnect', credentialId: input.credentialId }
180+
: { kind: 'create' },
181+
limit: CREDENTIAL_GROUP_ACCOUNTS_PER_PROVIDER_LIMIT,
182+
accounts: accounts.map((account) => ({ ...account, managedOauthStatus: 'active' })),
183+
identity: { subject: verified.principal.id, tenant: tenantId },
184+
})
167185
const now = new Date()
168-
const credentialId = input.credentialId ?? generateId()
186+
const credentialId = existing?.id ?? generateId()
169187
const fields = {
170188
displayName: input.displayName?.trim() || verified.displayName,
171189
description: input.description?.trim() || null,
172190
encryptedServiceAccountKey: verified.encryptedServiceAccountKey,
173-
providerSubjectId: verified.principal?.kind === 'user' ? verified.principal.id : null,
191+
providerSubjectId: verified.principal.id,
174192
providerTenantId: tenantId,
175193
revokedAt: null,
176194
updatedAt: now,
177195
}
178-
if (input.credentialId) {
179-
const updated = await tx
180-
.update(credential)
181-
.set(fields)
182-
.where(
183-
and(
184-
ownedCredential(context.organizationId, context.userId, credentialId),
185-
eq(credential.providerId, input.providerId)
186-
)
187-
)
188-
.returning({ id: credential.id })
189-
if (!updated.length)
190-
throw new OrchestrationError('not_found', 'Personal connection not found')
191-
} else {
196+
if (!existing || existing.revokedAt) {
192197
const [saved] = await tx
193198
.select({ count: count() })
194199
.from(credential)
@@ -208,6 +213,21 @@ export const saveSearchPersonalCredential: OperationUseCase<
208213
'Remove an unused personal Search connection before adding another'
209214
)
210215
}
216+
}
217+
if (existing) {
218+
const updated = await tx
219+
.update(credential)
220+
.set(fields)
221+
.where(
222+
and(
223+
ownedCredential(context.organizationId, context.userId, credentialId),
224+
eq(credential.providerId, input.providerId)
225+
)
226+
)
227+
.returning({ id: credential.id })
228+
if (!updated.length)
229+
throw new OrchestrationError('not_found', 'Personal connection not found')
230+
} else {
211231
await tx.insert(credential).values({
212232
id: credentialId,
213233
organizationId: context.organizationId,
@@ -225,7 +245,7 @@ export const saveSearchPersonalCredential: OperationUseCase<
225245
joinedAt: now,
226246
})
227247
}
228-
return { credentialId }
248+
return { credentialId, created: !existing }
229249
})
230250
recordProjectedUseCaseAuditEntries(
231251
this.operation,
@@ -234,17 +254,15 @@ export const saveSearchPersonalCredential: OperationUseCase<
234254
request,
235255
[
236256
{
237-
action: input.credentialId
238-
? AuditAction.CREDENTIAL_UPDATED
239-
: AuditAction.CREDENTIAL_CREATED,
257+
action: result.created ? AuditAction.CREDENTIAL_CREATED : AuditAction.CREDENTIAL_UPDATED,
240258
resourceType: AuditResourceType.CREDENTIAL,
241259
resourceId: result.credentialId,
242260
metadata: { providerId: input.providerId, ...verified.auditMetadata },
243261
},
244262
],
245263
context.organizationId
246264
)
247-
return result
265+
return { credentialId: result.credentialId }
248266
},
249267
}
250268

0 commit comments

Comments
 (0)