Skip to content

Commit bffe6df

Browse files
committed
fix(microsoft): preserve personal accounts in organization sources
1 parent 0316424 commit bffe6df

6 files changed

Lines changed: 273 additions & 14 deletions

File tree

‎apps/sim/lib/credentials/application/organization-credentials.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
organizationAuthorizationMock,
1717
organizationAuthorizationMockFns,
1818
} from '@sim/testing/mocks/organization-authorization.mock'
19-
import { and, eq, isNull, or } from 'drizzle-orm'
19+
import { and, eq, inArray, isNull, or } from 'drizzle-orm'
2020
import { beforeEach, describe, expect, it, vi } from 'vitest'
2121
import { OrchestrationError } from '@/lib/core/orchestration/types'
2222

@@ -151,7 +151,7 @@ describe('organization connection application boundary', () => {
151151
and(eq(credential.type, 'oauth'), eq(credential.createdBy, 'admin-1'))
152152
),
153153
eq(credential.type, 'oauth'),
154-
eq(credential.providerId, 'google-drive')
154+
inArray(credential.providerId, ['google-drive'])
155155
)
156156
)
157157
})

‎apps/sim/lib/credentials/application/organization-credentials.ts‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { type Principal, resolvePrincipalSubjectUserId } from '@sim/auth/principal'
33
import { db } from '@sim/db'
44
import { account, credential } from '@sim/db/schema'
5-
import { and, desc, eq, getTableColumns, or } from 'drizzle-orm'
5+
import { and, desc, eq, getTableColumns, inArray, or } from 'drizzle-orm'
66
import type {
77
CreateOrganizationCredentialBody,
88
CreateOrganizationCredentialDraftBody,
@@ -38,7 +38,7 @@ import {
3838
type ServiceAccountTokenResult,
3939
} from '@/lib/oauth/credential-service'
4040
import type { Credential, OAuthProvider } from '@/lib/oauth/types'
41-
import { getServiceConfigByProviderId } from '@/lib/oauth/utils'
41+
import { getServiceConfigByProviderId, providerIdsForService } from '@/lib/oauth/utils'
4242

4343
export const organizationCredentialOperations = {
4444
list: defineOrganizationOperation({
@@ -113,7 +113,9 @@ export const listOrganizationCredentials: OperationUseCase<
113113
and(eq(credential.type, 'oauth'), eq(credential.createdBy, context.userId))
114114
),
115115
...(input.type ? [eq(credential.type, input.type)] : []),
116-
...(input.providerId ? [eq(credential.providerId, input.providerId)] : [])
116+
...(input.providerId
117+
? [inArray(credential.providerId, providerIdsForService(input.providerId))]
118+
: [])
117119
)
118120
)
119121
.orderBy(desc(credential.createdAt))

‎apps/sim/lib/credentials/organization-managed.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { credential, credentialGroup, user } from '@sim/db/schema'
22
import { dbChainMockFns, queueTableRows, resetDbChainMock } from '@sim/testing'
3-
import { and, eq } from 'drizzle-orm'
3+
import { and, eq, inArray } from 'drizzle-orm'
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import {
66
resourceScopeCondition,
@@ -50,7 +50,7 @@ describe('own organization managed browsing credentials', () => {
5050
eq(credential.createdBy, 'user-1'),
5151
eq(user.id, 'user-1'),
5252
eq(user.emailVerified, true),
53-
eq(credential.providerId, 'jira'),
53+
inArray(credential.providerId, ['jira']),
5454
undefined
5555
)
5656
)

‎apps/sim/lib/credentials/organization-managed.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { db } from '@sim/db'
22
import { credential, credentialGroup, credentialGroupEnrollment, user } from '@sim/db/schema'
3-
import { and, desc, eq } from 'drizzle-orm'
3+
import { and, desc, eq, inArray } from 'drizzle-orm'
44
import {
55
resourceScopeCondition,
66
sameResourceScopeCondition,
77
} from '@/lib/core/resource-scope.server'
88
import { isManagedCredentialGroupBindingLive } from '@/lib/credential-groups/credentials'
9+
import { providerIdsForService } from '@/lib/oauth/utils'
910

1011
/** Lists only a verified person's currently usable organization grants, without token material. */
1112
export async function getOwnOrganizationManagedOAuthCredentials(input: {
@@ -44,7 +45,9 @@ export async function getOwnOrganizationManagedOAuthCredentials(input: {
4445
eq(credential.createdBy, input.userId),
4546
eq(user.id, input.userId),
4647
eq(user.emailVerified, true),
47-
input.providerId ? eq(credential.providerId, input.providerId) : undefined,
48+
input.providerId
49+
? inArray(credential.providerId, providerIdsForService(input.providerId))
50+
: undefined,
4851
input.credentialId ? eq(credential.id, input.credentialId) : undefined
4952
)
5053
)

‎apps/sim/lib/knowledge/connectors/member-provisioning.ts‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
import { inviteCredentialGroupEnrollment } from '@/lib/credential-groups/enrollments'
3030
import { requireOrganizationAccountsSetup } from '@/lib/credential-groups/organization-setup'
3131
import { CredentialGroupProviderConfigurationError } from '@/lib/credential-groups/provider-adapter'
32+
import { listConfiguredCredentialGroupProviders } from '@/lib/credential-groups/provider-availability'
3233
import { getCredentialGroupProviderAdapter } from '@/lib/credential-groups/provider-registry'
3334
import {
3435
findCredentialGroupProviderFromProviderId,
@@ -42,6 +43,7 @@ import {
4243
resolveKnowledgeAccessAvailability,
4344
} from '@/lib/knowledge/access/availability'
4445
import { validateKnowledgeConnectorMembersBinding } from '@/lib/knowledge/connectors/member-access'
46+
import { providerIdsForService } from '@/lib/oauth/utils'
4547
import { getConnectorMeta } from '@/connectors/registry'
4648
import type { ConnectorMeta } from '@/connectors/types'
4749

@@ -92,25 +94,40 @@ export async function provisionKnowledgeConnectorMembersBinding(input: {
9294
if (connectorMeta.auth.mode !== 'oauth') {
9395
throw new OrchestrationError('validation', 'Only an OAuth connector can sync per member')
9496
}
95-
const providerId = connectorMeta.auth.provider
96-
const provider = findCredentialGroupProviderFromProviderId(providerId)
97-
if (!provider) {
97+
const providers = providerIdsForService(connectorMeta.auth.provider)
98+
.map(findCredentialGroupProviderFromProviderId)
99+
.filter((provider) => provider !== null)
100+
const defaultProvider = providers[0]
101+
if (!defaultProvider) {
98102
throw new OrchestrationError(
99103
'validation',
100104
`${connectorMeta.name} accounts cannot be collected through a Credential Group yet`
101105
)
102106
}
103107

108+
const scope = resourceScopeFromOwner(input)
109+
const existing = await loadScopedAccountsCredentialListContext(scope)
110+
const existingOptions =
111+
existing?.options.filter((option) =>
112+
providers.some((provider) => provider === option.provider)
113+
) ?? []
114+
const activeOptions = existingOptions.filter((option) => option.status === 'active')
115+
const existingOption = activeOptions[0] ?? existingOptions[0]
116+
const configuredProviders = listConfiguredCredentialGroupProviders()
117+
const provider =
118+
providers.find((candidate) => candidate === existingOption?.provider) ??
119+
providers.find((candidate) => configuredProviders.includes(candidate)) ??
120+
defaultProvider
104121
const group = await ensureWorkspaceAccountsGroup(
105-
resourceScopeFromOwner(input),
122+
scope,
106123
input.userId,
107124
isCredentialGroupStandardOAuthProvider(provider)
108125
? { provider, label: connectorMeta.name, required: false }
109126
: undefined
110127
)
111128
const options = group.options.filter(
112129
(option) =>
113-
option.provider === provider &&
130+
providers.some((candidate) => candidate === option.provider) &&
114131
option.status === 'active' &&
115132
option.configurationStatus === 'ready'
116133
)
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import { db } from '@sim/db'
2+
import {
3+
account,
4+
credential,
5+
credentialGroup,
6+
credentialGroupEnrollment,
7+
member,
8+
organization,
9+
user,
10+
workspace,
11+
} from '@sim/db/schema'
12+
import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
13+
import { generateId } from '@sim/utils/id'
14+
import { eq, inArray, or } from 'drizzle-orm'
15+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
16+
import { env } from '@/lib/core/config/env'
17+
import { ensureWorkspaceAccountsGroup } from '@/lib/credential-groups/service'
18+
import { listOrganizationOAuthCredentials } from '@/lib/credentials/application/organization-credentials'
19+
import { provisionKnowledgeConnectorMembersBinding } from '@/lib/knowledge/connectors/member-provisioning'
20+
import { onedriveConnectorMeta } from '@/connectors/onedrive/meta'
21+
22+
/** Real storage and authorization; CI writes the shared INTEGRATION_REPORT_PATH JSON artifact. */
23+
describe('personal Microsoft sources', () => {
24+
let ownerId: string
25+
let otherId: string
26+
let organizationId: string
27+
let foreignOrganizationId: string
28+
let workspaceId: string
29+
30+
beforeEach(async () => {
31+
Object.assign(env, {
32+
MICROSOFT_CLIENT_ID: 'existing-client',
33+
MICROSOFT_CLIENT_SECRET: 'existing-client-secret',
34+
MICROSOFT_PERSONAL_CLIENT_ID: 'personal-client',
35+
MICROSOFT_PERSONAL_CLIENT_SECRET: 'personal-client-secret',
36+
})
37+
ownerId = generateId()
38+
otherId = generateId()
39+
organizationId = generateId()
40+
foreignOrganizationId = generateId()
41+
workspaceId = generateId()
42+
await db.insert(user).values(
43+
[ownerId, otherId].map((id) => ({
44+
id,
45+
name: 'OAuth fixture',
46+
email: `${id}@fixture.test`,
47+
emailVerified: true,
48+
createdAt: new Date(),
49+
updatedAt: new Date(),
50+
}))
51+
)
52+
await db.insert(organization).values(
53+
[organizationId, foreignOrganizationId].map((id) => ({
54+
id,
55+
name: 'OAuth fixture organization',
56+
slug: id,
57+
}))
58+
)
59+
await db.insert(member).values({
60+
id: generateId(),
61+
organizationId,
62+
userId: ownerId,
63+
role: 'owner',
64+
})
65+
await db.insert(workspace).values({
66+
id: workspaceId,
67+
name: 'OAuth fixture workspace',
68+
organizationId,
69+
ownerId,
70+
billedAccountUserId: ownerId,
71+
})
72+
})
73+
74+
afterEach(async () => {
75+
await db
76+
.delete(credentialGroup)
77+
.where(
78+
or(
79+
eq(credentialGroup.workspaceId, workspaceId),
80+
inArray(credentialGroup.organizationId, [organizationId, foreignOrganizationId])
81+
)
82+
)
83+
await db.delete(workspace).where(eq(workspace.id, workspaceId))
84+
await db
85+
.delete(organization)
86+
.where(inArray(organization.id, [organizationId, foreignOrganizationId]))
87+
await db.delete(user).where(inArray(user.id, [ownerId, otherId]))
88+
})
89+
90+
const provision = () =>
91+
provisionKnowledgeConnectorMembersBinding({
92+
workspaceId,
93+
userId: ownerId,
94+
connectorMeta: onedriveConnectorMeta,
95+
})
96+
97+
it.each([false, true])(
98+
'keeps the existing personal source binding when the work app is configured: %s',
99+
async (workAppConfigured) => {
100+
const group = await ensureWorkspaceAccountsGroup(workspaceId, ownerId, {
101+
provider: 'onedrive-personal',
102+
label: 'OneDrive',
103+
required: false,
104+
})
105+
if (!workAppConfigured) {
106+
Object.assign(env, { MICROSOFT_CLIENT_ID: undefined, MICROSOFT_CLIENT_SECRET: undefined })
107+
}
108+
expect(await provision()).toEqual({
109+
credentialGroupId: group.id,
110+
credentialGroupOptionId: group.options[0].id,
111+
})
112+
const [stored] = await db
113+
.select()
114+
.from(credentialGroup)
115+
.where(eq(credentialGroup.id, group.id))
116+
expect(stored.options.map((option) => option.provider)).toEqual(['onedrive-personal'])
117+
}
118+
)
119+
120+
it('provisions the personal provider when only that app is configured', async () => {
121+
Object.assign(env, { MICROSOFT_CLIENT_ID: undefined, MICROSOFT_CLIENT_SECRET: undefined })
122+
const binding = await provision()
123+
const [stored] = await db
124+
.select()
125+
.from(credentialGroup)
126+
.where(eq(credentialGroup.id, binding.credentialGroupId))
127+
expect(stored.options).toHaveLength(1)
128+
expect(stored.options[0]).toMatchObject({
129+
id: binding.credentialGroupOptionId,
130+
provider: 'onedrive-personal',
131+
})
132+
})
133+
134+
it('refuses to guess between two active account types', async () => {
135+
await ensureWorkspaceAccountsGroup(workspaceId, ownerId, {
136+
provider: 'onedrive-personal',
137+
label: 'Personal OneDrive',
138+
required: false,
139+
})
140+
await ensureWorkspaceAccountsGroup(workspaceId, ownerId, {
141+
provider: 'onedrive',
142+
label: 'Work OneDrive',
143+
required: false,
144+
})
145+
await expect(provision()).rejects.toThrow('member sign-in')
146+
})
147+
148+
async function addOAuth(providerId: string, createdBy = ownerId, orgId = organizationId) {
149+
const accountId = generateId()
150+
const credentialId = generateId()
151+
await db.insert(account).values({
152+
id: accountId,
153+
accountId,
154+
providerId,
155+
userId: createdBy,
156+
scope: 'openid Files.Read',
157+
createdAt: new Date(),
158+
updatedAt: new Date(),
159+
})
160+
await db.insert(credential).values({
161+
id: credentialId,
162+
organizationId: orgId,
163+
accountId,
164+
type: 'oauth',
165+
displayName: 'Fixture connection',
166+
providerId,
167+
createdBy,
168+
})
169+
return credentialId
170+
}
171+
172+
const list = (providerId: string, purpose?: 'browsing') =>
173+
listOrganizationOAuthCredentials.execute({
174+
principal: createSessionPrincipal({ userId: ownerId }),
175+
input: { organizationId, providerId, purpose },
176+
})
177+
178+
it('lists compatible personal grants without crossing user, organization or service boundaries', async () => {
179+
const workId = await addOAuth('onedrive')
180+
const personalId = await addOAuth('onedrive-personal')
181+
await addOAuth('onedrive-personal', otherId)
182+
await addOAuth('onedrive-personal', ownerId, foreignOrganizationId)
183+
await addOAuth('outlook-personal')
184+
expect((await list('onedrive')).credentials.map(({ id }) => id).sort()).toEqual(
185+
[workId, personalId].sort()
186+
)
187+
expect((await list('onedrive-personal')).credentials.map(({ id }) => id)).toEqual([personalId])
188+
})
189+
190+
it('includes the caller’s live personal enrollment for browsing, without allowing indexing', async () => {
191+
const group = await ensureWorkspaceAccountsGroup(
192+
{ kind: 'organization', organizationId },
193+
ownerId,
194+
{ provider: 'onedrive-personal', label: 'Personal OneDrive', required: false }
195+
)
196+
const [stored] = await db.select().from(credentialGroup).where(eq(credentialGroup.id, group.id))
197+
const option = stored.options[0]
198+
const enrollmentId = generateId()
199+
const credentialId = generateId()
200+
await db.insert(credentialGroupEnrollment).values({
201+
id: enrollmentId,
202+
credentialGroupId: group.id,
203+
email: `${ownerId}@fixture.test`,
204+
userId: ownerId,
205+
status: 'completed',
206+
invitationTokenHash: 'a'.repeat(64),
207+
invitationExpiresAt: new Date(Date.now() + 60_000),
208+
invitedAt: new Date(),
209+
})
210+
await db.insert(credential).values({
211+
id: credentialId,
212+
organizationId,
213+
type: 'managed_oauth',
214+
displayName: 'Personal OneDrive',
215+
providerId: 'onedrive-personal',
216+
createdBy: ownerId,
217+
credentialGroupEnrollmentId: enrollmentId,
218+
credentialGroupOptionId: option.id,
219+
authorizationAppId: option.authorizationAppId,
220+
managedOauthScopeVersion: option.scopeVersion,
221+
providerSubjectId: 'fixture-subject',
222+
managedOauthStatus: 'active',
223+
grantedScopes: option.requiredScopes,
224+
encryptedOauthTokenSet: 'not-read-by-credential-lists',
225+
grantedAt: new Date(),
226+
})
227+
expect((await list('onedrive', 'browsing')).credentials.map(({ id }) => id)).toEqual([
228+
credentialId,
229+
])
230+
expect((await list('onedrive')).credentials).toEqual([])
231+
await db
232+
.update(credentialGroupEnrollment)
233+
.set({ status: 'revoked' })
234+
.where(eq(credentialGroupEnrollment.id, enrollmentId))
235+
expect((await list('onedrive', 'browsing')).credentials).toEqual([])
236+
})
237+
})

0 commit comments

Comments
 (0)