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
6 changes: 3 additions & 3 deletions apps/sim/app/api/auth/oauth/credentials/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { getValidationErrorMessage } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access'
import { syncWorkspaceOAuthCredentialsForUser } from '@/lib/credentials/oauth'
import {
getCanonicalScopesForProvider,
Expand Down Expand Up @@ -155,7 +155,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {

if (!workflowId) {
const access = await getCredentialActorContext(platformCredential.id, requesterUserId)
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
if (!canUseCredential(access)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
Expand Down Expand Up @@ -187,7 +187,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
}
} else {
const access = await getCredentialActorContext(platformCredential.id, requesterUserId)
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
if (!canUseCredential(access)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
}
Expand Down
8 changes: 6 additions & 2 deletions apps/sim/app/api/credentials/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,11 @@ import { updateWorkspaceCredentialContract } from '@/lib/api/contracts/credentia
import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { type CredentialActorContext, getCredentialActorContext } from '@/lib/credentials/access'
import {
type CredentialActorContext,
canUseCredential,
getCredentialActorContext,
} from '@/lib/credentials/access'
import {
isProviderOutageCode,
performDeleteCredential,
Expand Down Expand Up @@ -49,7 +53,7 @@ export const GET = withRouteHandler(
if (!access.credential) {
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
}
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
if (!canUseCredential(access)) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const { mockResolveAutoModel } = vi.hoisted(() => ({
vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)

vi.mock('@/lib/credentials/access', () => ({
canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) =>
access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin),
getCredentialActorContext: vi.fn().mockResolvedValue({
credential: {
id: 'test-vertex-credential-id',
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/executor/handlers/router/router-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ vi.mock('@/lib/oauth/credential-service', () => authOAuthUtilsMock)
vi.mock('@/lib/core/security/encryption', () => encryptionMock)

vi.mock('@/lib/credentials/access', () => ({
canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) =>
access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin),
getCredentialActorContext: vi.fn().mockResolvedValue({
credential: {
id: 'test-vertex-credential',
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/executor/utils/vertex-credential.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const { mockGetCredentialActorContext, mockGetServiceAccountToken, mockRefreshTo

vi.mock('@/lib/credentials/access', () => ({
getCredentialActorContext: mockGetCredentialActorContext,
canUseCredential: (access: { hasWorkspaceAccess: boolean; member: unknown; isAdmin: boolean }) =>
access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin),
}))
vi.mock('@/lib/oauth/credential-service', () => ({
getServiceAccountToken: mockGetServiceAccountToken,
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/executor/utils/vertex-credential.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { db } from '@sim/db'
import { account } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { getCredentialActorContext } from '@/lib/credentials/access'
import { canUseCredential, getCredentialActorContext } from '@/lib/credentials/access'
import { getServiceAccountToken, refreshTokenIfNeeded } from '@/lib/oauth/credential-service'

const logger = createLogger('VertexCredential')
Expand Down Expand Up @@ -48,7 +48,7 @@ export async function resolveVertexCredential({
})
throw new Error('Credential is not accessible from this workflow workspace')
}
if (!access.hasWorkspaceAccess || (!access.member && !access.isAdmin)) {
if (!canUseCredential(access)) {
throw new Error('Not authorized to use this Vertex AI credential')
}

Expand Down
238 changes: 238 additions & 0 deletions apps/sim/lib/auth/credential-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
/**
* @vitest-environment node
*/
import { account, credential, credentialMember, workflow } from '@sim/db/schema'
import { createMockRequest, queueTableRows, resetDbChainMock } from '@sim/testing'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'

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

vi.mock('@/lib/auth/hybrid', () => ({
AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' },
checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth,
}))

vi.mock('@/lib/workspaces/permissions/utils', () => ({
checkWorkspaceAccess: mockResolveWorkspaceAccess,
getUserEntityPermissions: mockGetUserEntityPermissions,
resolveWorkspaceAccess: mockResolveWorkspaceAccess,
}))

import { authorizeCredentialUse } from '@/lib/auth/credential-access'

afterAll(resetDbChainMock)

const OWNER = 'owner-user'
const WORKSPACE = 'ws-1'
const ACCOUNT_ID = 'acct-1'

const workspaceAdmin = { hasAccess: true, canWrite: true, canAdmin: true }
const workspaceWriter = { hasAccess: true, canWrite: true, canAdmin: false }
const noWorkspaceAccess = { hasAccess: false, canWrite: false, canAdmin: false }

const platformCredential = {
id: 'cred-1',
workspaceId: WORKSPACE,
type: 'oauth',
accountId: ACCOUNT_ID,
}

function actAs(userId: string) {
mockCheckSessionOrInternalAuth.mockResolvedValue({ success: true, userId, authType: 'session' })
}

/** The rows `getCredentialActorContext` reads: the credential, then the caller's membership. */
function queueActorContext(
credentialRow: Record<string, unknown>,
membership: { role: string }[] = []
) {
queueTableRows(credential, [credentialRow])
queueTableRows(credentialMember, membership)
}

/** The rows `resolveCredentialTokenIdentity` reads: the credential, then its account. */
function queueTokenIdentity(
credentialRow: Record<string, unknown> | null,
ownerUserId: string | null
) {
queueTableRows(credential, credentialRow ? [credentialRow] : [])
queueTableRows(account, ownerUserId ? [{ userId: ownerUserId }] : [])
}

function authorize(credentialId: string, workflowId?: string) {
return authorizeCredentialUse(createMockRequest('POST'), { credentialId, workflowId })
}

describe('authorizeCredentialUse', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
actAs('acting-user')
mockGetUserEntityPermissions.mockResolvedValue('admin')
mockResolveWorkspaceAccess.mockResolvedValue(workspaceWriter)
})

describe('workspace-scoped credentials, without a workflow', () => {
it('authorizes a workspace admin who did not run the OAuth flow', async () => {
queueActorContext(platformCredential)
queueTokenIdentity(platformCredential, OWNER)
mockResolveWorkspaceAccess.mockResolvedValue(workspaceAdmin)

const result = await authorize('cred-1')

expect(result.ok).toBe(true)
expect(result.credentialOwnerUserId).toBe(OWNER)
expect(result.resolvedCredentialId).toBe(ACCOUNT_ID)
expect(result.workspaceId).toBe(WORKSPACE)
})

it('authorizes an active credential member who did not run the OAuth flow', async () => {
queueActorContext(platformCredential, [{ role: 'member' }])
queueTokenIdentity(platformCredential, OWNER)

const result = await authorize('cred-1')

expect(result.ok).toBe(true)
expect(result.credentialOwnerUserId).toBe(OWNER)
})

it('rejects a workspace member who is not a credential member', async () => {
queueActorContext(platformCredential)

const result = await authorize('cred-1')

expect(result.ok).toBe(false)
expect(result.error).toContain('add you as a member')
})

it('rejects a caller who has lost access to the credential workspace', async () => {
queueActorContext(platformCredential)
mockResolveWorkspaceAccess.mockResolvedValue(noWorkspaceAccess)

const result = await authorize('cred-1')

expect(result.ok).toBe(false)
expect(result.error).toBe('You do not have access to this workspace.')
})

it('rejects when the credential owner has lost access to the workspace', async () => {
queueActorContext(platformCredential)
queueTokenIdentity(platformCredential, OWNER)
mockResolveWorkspaceAccess.mockResolvedValue(workspaceAdmin)
mockGetUserEntityPermissions.mockResolvedValue(null)

const result = await authorize('cred-1')

expect(result.ok).toBe(false)
expect(result.error).toBe('Unauthorized')
})
})

describe('workflow scope', () => {
it('rejects a credential belonging to another workspace', async () => {
queueTableRows(workflow, [{ workspaceId: 'other-ws' }])
queueActorContext(platformCredential)

const result = await authorize('cred-1', 'wf-1')

expect(result.ok).toBe(false)
expect(result.error).toBe('Credential is not accessible from this workflow workspace')
})
})

describe('legacy account ids', () => {
const sharedRow = { id: 'cred-1', workspaceId: WORKSPACE, type: 'oauth' }

it('resolves through an accessible workspace credential without a workflow', async () => {
queueTableRows(credential, []) // platform lookup miss
queueTableRows(credential, [sharedRow]) // shared rows wrapping the account
queueActorContext(sharedRow)
queueTokenIdentity(null, OWNER)
mockResolveWorkspaceAccess.mockResolvedValue(workspaceAdmin)

const result = await authorize(ACCOUNT_ID)

expect(result.ok).toBe(true)
expect(result.credentialOwnerUserId).toBe(OWNER)
expect(result.workspaceId).toBe(WORKSPACE)
expect(result.resolvedCredentialId).toBe(ACCOUNT_ID)
})

it('rejects when no workspace credential is reachable by the caller', async () => {
queueTableRows(credential, [])
queueTableRows(credential, [sharedRow])
queueActorContext(sharedRow)
queueTableRows(account, [{ userId: OWNER }])

const result = await authorize(ACCOUNT_ID)

expect(result.ok).toBe(false)
expect(result.error).toContain('add you as a member')
})

it('still authorizes the owner when a shared row rejects them', async () => {
actAs(OWNER)
queueTableRows(credential, [])
queueTableRows(credential, [{ id: 'cred-1', workspaceId: 'other-ws', type: 'oauth' }])
queueActorContext({ id: 'cred-1', workspaceId: 'other-ws', type: 'oauth' })
queueTableRows(account, [{ userId: OWNER }])

const result = await authorize(ACCOUNT_ID)

expect(result.ok).toBe(true)
expect(result.credentialOwnerUserId).toBe(OWNER)
})

it('does not fall back to the owner path when a workflow pins the workspace', async () => {
actAs(OWNER)
queueTableRows(workflow, [{ workspaceId: WORKSPACE }])
queueTableRows(credential, [])
queueTableRows(credential, [])
queueTableRows(account, [{ userId: OWNER }])

const result = await authorize(ACCOUNT_ID, 'wf-1')

expect(result.ok).toBe(false)
expect(result.error).toBe('Credential not found')
})

it('keeps an unshared account private to its owner', async () => {
queueTableRows(credential, [])
queueTableRows(credential, [])
queueTableRows(account, [{ userId: OWNER }])

const result = await authorize(ACCOUNT_ID)

expect(result.ok).toBe(false)
expect(result.error).toBe('Unauthorized')
})

it('authorizes the owner of an unshared account', async () => {
actAs(OWNER)
queueTableRows(credential, [])
queueTableRows(credential, [])
queueTableRows(account, [{ userId: OWNER }])

const result = await authorize(ACCOUNT_ID)

expect(result.ok).toBe(true)
expect(result.credentialOwnerUserId).toBe(OWNER)
})

it('reports an unknown credential id', async () => {
queueTableRows(credential, [])
queueTableRows(credential, [])
queueTableRows(account, [])

const result = await authorize('nope')

expect(result.ok).toBe(false)
expect(result.error).toBe('Credential not found')
})
})
})
Loading
Loading