Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 26 additions & 18 deletions apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import {
document,
embedding,
knowledgeBase,
knowledgeConnector,
knowledgeConnectorSyncLog,
} from '@sim/db/schema'
import { document, embedding, knowledgeConnector, knowledgeConnectorSyncLog } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
Expand All @@ -17,6 +11,7 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { hasWorkspaceLiveSyncAccess } from '@/lib/billing/core/subscription'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { resolveCredentialTokenIdentity } from '@/lib/credentials/access'
import { deleteDocumentStorageFiles } from '@/lib/knowledge/documents/service'
import { cleanupUnusedTagDefinitions } from '@/lib/knowledge/tags/service'
import { captureServerEvent } from '@/lib/posthog/server'
Expand Down Expand Up @@ -157,16 +152,6 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
)
}

const kbRows = await db
.select({ userId: knowledgeBase.userId })
.from(knowledgeBase)
.where(eq(knowledgeBase.id, knowledgeBaseId))
.limit(1)

if (kbRows.length === 0) {
return NextResponse.json({ error: 'Knowledge base not found' }, { status: 404 })
}

let accessToken: string | null = null
if (connectorConfig.auth.mode === 'apiKey') {
if (!existing.encryptedApiKey) {
Expand All @@ -183,9 +168,32 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
{ status: 400 }
)
}
const connectorWorkspaceId = writeCheck.knowledgeBase.workspaceId
if (!connectorWorkspaceId) {
return NextResponse.json(
{ error: 'Knowledge base is missing workspace context' },
{ status: 409 }
)
}
/**
* Resolve the credential's own account owner, not the knowledge base owner:
* workspace credentials are shared, and token reads are scoped to
* `account.userId`.
*/
const identity = await resolveCredentialTokenIdentity(
existing.credentialId,
connectorWorkspaceId
)
if (!identity) {
return NextResponse.json(
{ error: 'Credential is no longer usable in this workspace. Please reconnect it.' },
{ status: 400 }
)
}
accessToken = await refreshAccessTokenIfNeeded(
existing.credentialId,
kbRows[0].userId,
// Service accounts mint their own token and ignore the acting user.
identity.kind === 'oauth' ? identity.userId : auth.userId,
`patch-${connectorId}`
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,14 +92,28 @@ export function AddConnectorModal({
)

const {
data: credentials = [],
data: rawCredentials = [],
isLoading: credentialsLoading,
refetch: refetchCredentials,
} = useOAuthCredentials(connectorProviderId ?? undefined, {
enabled: Boolean(connectorConfig) && !isApiKeyMode,
workspaceId,
})

/**
* The credential list also returns the provider's service accounts, but
* `ConnectorAuthConfig` has no service-account mode: the sync engine resolves
* connector tokens through `refreshAccessTokenIfNeeded`, which passes no scopes
* and drops the `cloudId`/`domain`/`authStyle` a service account resolves with.
* Offering them here would surface credentials no connector can authenticate
* with, so — like a workflow picker that has not opted in via
* `allowServiceAccounts` — list OAuth accounts only.
*/
const credentials = useMemo(
() => rawCredentials.filter((cred) => cred.type !== 'service_account'),
[rawCredentials]
)

useCredentialRefreshTriggers(refetchCredentials, connectorProviderId ?? '', workspaceId)

const effectiveCredentialId =
Expand Down
79 changes: 76 additions & 3 deletions apps/sim/lib/credentials/access.test.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import { credential, credentialMember } from '@sim/db/schema'
import { account, credential, credentialMember } from '@sim/db/schema'
import { queueTableRows, resetDbChainMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockCheckWorkspaceAccess } = vi.hoisted(() => ({
const { mockCheckWorkspaceAccess, mockGetUserEntityPermissions } = vi.hoisted(() => ({
mockCheckWorkspaceAccess: vi.fn(),
mockGetUserEntityPermissions: vi.fn(),
}))

vi.mock('@/lib/workspaces/permissions/utils', () => ({
checkWorkspaceAccess: mockCheckWorkspaceAccess,
getUserEntityPermissions: mockGetUserEntityPermissions,
resolveWorkspaceAccess: vi.fn(async (workspaceId: string, userId: string, provided?: any) =>
provided && provided.workspace?.id === workspaceId
? provided
: mockCheckWorkspaceAccess(workspaceId, userId)
),
}))

import { getCredentialActorContext } from '@/lib/credentials/access'
import { getCredentialActorContext, resolveCredentialTokenIdentity } from '@/lib/credentials/access'

afterAll(resetDbChainMock)

Expand Down Expand Up @@ -88,3 +90,74 @@ describe('getCredentialActorContext', () => {
expect(ctx.isAdmin).toBe(false)
})
})

describe('resolveCredentialTokenIdentity', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
})

it('resolves the account owner even when it is not the caller', async () => {
queueTableRows(credential, [{ workspaceId: 'ws', type: 'oauth', accountId: 'acct1' }])
queueTableRows(account, [{ userId: 'authorizer' }])
mockGetUserEntityPermissions.mockResolvedValue('write')

await expect(resolveCredentialTokenIdentity('c1', 'ws')).resolves.toEqual({
kind: 'oauth',
userId: 'authorizer',
})
})

it('rejects a credential belonging to another workspace', async () => {
queueTableRows(credential, [{ workspaceId: 'other-ws', type: 'oauth', accountId: 'acct1' }])

await expect(resolveCredentialTokenIdentity('c1', 'ws')).resolves.toBeNull()
})

it('reports service accounts as needing no user id', async () => {
queueTableRows(credential, [{ workspaceId: 'ws', type: 'service_account', accountId: null }])

await expect(resolveCredentialTokenIdentity('c1', 'ws')).resolves.toEqual({
kind: 'service_account',
})
})

it('rejects a service account from another workspace', async () => {
queueTableRows(credential, [
{ workspaceId: 'other-ws', type: 'service_account', accountId: null },
])

await expect(resolveCredentialTokenIdentity('c1', 'ws')).resolves.toBeNull()
})

it('rejects a credential type that is neither oauth nor a service account', async () => {
queueTableRows(credential, [{ workspaceId: 'ws', type: 'env_personal', accountId: null }])

await expect(resolveCredentialTokenIdentity('c1', 'ws')).resolves.toBeNull()
})

it('rejects when the owner no longer has workspace access', async () => {
queueTableRows(credential, [{ workspaceId: 'ws', type: 'oauth', accountId: 'acct1' }])
queueTableRows(account, [{ userId: 'departed' }])
mockGetUserEntityPermissions.mockResolvedValue(null)

await expect(resolveCredentialTokenIdentity('c1', 'ws')).resolves.toBeNull()
})

it('falls back to a legacy raw account id when no credential row exists', async () => {
queueTableRows(account, [{ userId: 'legacy-owner' }])
mockGetUserEntityPermissions.mockResolvedValue('admin')

await expect(resolveCredentialTokenIdentity('acct-legacy', 'ws')).resolves.toEqual({
kind: 'oauth',
userId: 'legacy-owner',
})
})

it('returns null when the account row is missing', async () => {
queueTableRows(credential, [{ workspaceId: 'ws', type: 'oauth', accountId: 'acct1' }])

await expect(resolveCredentialTokenIdentity('c1', 'ws')).resolves.toBeNull()
expect(mockGetUserEntityPermissions).not.toHaveBeenCalled()
})
})
68 changes: 66 additions & 2 deletions apps/sim/lib/credentials/access.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { db } from '@sim/db'
import { credential, credentialMember, credentialTypeEnum } from '@sim/db/schema'
import { account, credential, credentialMember, credentialTypeEnum } from '@sim/db/schema'
import { and, eq, inArray } from 'drizzle-orm'
import type { DbOrTx } from '@/lib/db/types'
import { resolveWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import {
getUserEntityPermissions,
resolveWorkspaceAccess,
type WorkspaceAccess,
} from '@/lib/workspaces/permissions/utils'

type ActiveCredentialMember = typeof credentialMember.$inferSelect
type CredentialRecord = typeof credential.$inferSelect
Expand All @@ -19,6 +23,66 @@ export const SHARED_CREDENTIAL_TYPES = credentialTypeEnum.enumValues.filter(
(type) => type !== 'env_personal'
)

/**
* Which user a credential's token must be read as.
*
* Service-account credentials mint their own token and ignore the acting user
* entirely, so they carry no user id — callers pass their existing one through.
*/
export type CredentialTokenIdentity =
| { kind: 'service_account' }
| { kind: 'oauth'; userId: string }

/**
* Resolves which user a credential's token must be read as, for background jobs
* that run without a request context (connector syncs, scheduled runs).
*
* Workspace-scoped OAuth credentials are shared, so the member who authorized one
* is frequently not the user driving the job. Token reads are scoped to
* `account.userId`, so a job passing its own user id resolves no token at all.
* Mirrors the ownership resolution in `authorizeCredentialUse`: the credential must
* belong to `workspaceId`, and its owner must still have access to that workspace.
*
* @returns the identity to read the token as, or `null` when the credential is
* unusable from this workspace (wrong workspace, missing account, owner lost access).
*/
export async function resolveCredentialTokenIdentity(
credentialId: string,
workspaceId: string
): Promise<CredentialTokenIdentity | null> {
const [platformCredential] = await db
.select({
workspaceId: credential.workspaceId,
type: credential.type,
accountId: credential.accountId,
})
.from(credential)
.where(eq(credential.id, credentialId))
.limit(1)

if (platformCredential) {
if (platformCredential.workspaceId !== workspaceId) return null
if (platformCredential.type === 'service_account') return { kind: 'service_account' }
if (platformCredential.type !== 'oauth' || !platformCredential.accountId) return null
}

// Credentials predating the workspace-scoped `credential` table are raw account ids.
const accountId = platformCredential?.accountId ?? credentialId

const [accountRow] = await db
.select({ userId: account.userId })
.from(account)
.where(eq(account.id, accountId))
.limit(1)

if (!accountRow) return null

const ownerPerm = await getUserEntityPermissions(accountRow.userId, 'workspace', workspaceId)
if (ownerPerm === null) return null

return { kind: 'oauth', userId: accountRow.userId }
}

/** Whether a credential is shared at the workspace level (i.e. not a personal env var). */
export function isSharedCredentialType(type: CredentialType): boolean {
return type !== 'env_personal'
Expand Down
35 changes: 32 additions & 3 deletions apps/sim/lib/knowledge/connectors/sync-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
type BillingAttributionSnapshot,
} from '@/lib/billing/core/billing-attribution'
import { getInternalApiBaseUrl } from '@/lib/core/utils/urls'
import { resolveCredentialTokenIdentity } from '@/lib/credentials/access'
import type { DocumentData } from '@/lib/knowledge/documents/service'
import { hardDeleteDocuments, processDocumentsWithQueue } from '@/lib/knowledge/documents/service'
import { StorageService } from '@/lib/uploads'
Expand Down Expand Up @@ -377,6 +378,10 @@ export function resolveTagMapping(
* Resolves an access token for a connector based on its auth mode.
* OAuth connectors refresh via the credential system; API key connectors
* decrypt the key stored in the dedicated `encryptedApiKey` column.
*
* `userId` must be the user who owns the credential's OAuth account — not the
* knowledge base owner. Workspace-scoped credentials are routinely authorized by
* a different member, and token reads are scoped to `account.userId`.
*/
async function resolveAccessToken(
connector: { credentialId: string | null; encryptedApiKey: string | null },
Expand Down Expand Up @@ -529,7 +534,31 @@ export async function executeSync(
let syncExitedCleanly = false

try {
let accessToken = await resolveAccessToken(connector, connectorConfig, userId)
/**
* OAuth credentials are workspace-scoped and shared, so the member who authorized
* one is often not the knowledge base owner. Resolve the credential's own account
* owner — token reads are scoped to `account.userId`, so passing the KB owner
* resolves no token at all. Resolved once here rather than inside
* `resolveAccessToken` so per-page refreshes don't repeat the lookup.
*/
let credentialUserId = userId
if (connectorConfig.auth.mode === 'oauth' && connector.credentialId) {
const identity = await resolveCredentialTokenIdentity(
connector.credentialId,
kbOwner.workspaceId
)
if (!identity) {
throw new Error(
`Credential ${connector.credentialId} is not usable from workspace ${kbOwner.workspaceId} — reconnect the credential`
)
}
// Service accounts mint their own token and ignore the acting user.
if (identity.kind === 'oauth') {
credentialUserId = identity.userId
}
}

let accessToken = await resolveAccessToken(connector, connectorConfig, credentialUserId)

const externalDocs: ExternalDocument[] = []
let cursor: string | undefined
Expand Down Expand Up @@ -605,7 +634,7 @@ export async function executeSync(

for (let pageNum = 0; hasMore && pageNum < MAX_PAGES; pageNum++) {
if (pageNum > 0 && connectorConfig.auth.mode === 'oauth') {
accessToken = await resolveAccessToken(connector, connectorConfig, userId)
accessToken = await resolveAccessToken(connector, connectorConfig, credentialUserId)
}

const page = await connectorConfig.listDocuments(
Expand Down Expand Up @@ -795,7 +824,7 @@ export async function executeSync(

if (deferredOps.length > 0) {
if (connectorConfig.auth.mode === 'oauth') {
accessToken = await resolveAccessToken(connector, connectorConfig, userId)
accessToken = await resolveAccessToken(connector, connectorConfig, credentialUserId)
}

const hydrated = await Promise.allSettled(
Expand Down
Loading