diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index bcff7e3b7e3..cdf25f7f159 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -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, @@ -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 }) } } @@ -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 }) } } diff --git a/apps/sim/app/api/credentials/[id]/route.ts b/apps/sim/app/api/credentials/[id]/route.ts index 71161b0d267..e82dca826df 100644 --- a/apps/sim/app/api/credentials/[id]/route.ts +++ b/apps/sim/app/api/credentials/[id]/route.ts @@ -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, @@ -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 }) } diff --git a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts index deb9cab0314..756b98798e2 100644 --- a/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts +++ b/apps/sim/executor/handlers/evaluator/evaluator-handler.test.ts @@ -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', diff --git a/apps/sim/executor/handlers/router/router-handler.test.ts b/apps/sim/executor/handlers/router/router-handler.test.ts index 53bf0382ff6..a7fe9e45141 100644 --- a/apps/sim/executor/handlers/router/router-handler.test.ts +++ b/apps/sim/executor/handlers/router/router-handler.test.ts @@ -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', diff --git a/apps/sim/executor/utils/vertex-credential.test.ts b/apps/sim/executor/utils/vertex-credential.test.ts index 242da2ad413..78454621bbf 100644 --- a/apps/sim/executor/utils/vertex-credential.test.ts +++ b/apps/sim/executor/utils/vertex-credential.test.ts @@ -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, diff --git a/apps/sim/executor/utils/vertex-credential.ts b/apps/sim/executor/utils/vertex-credential.ts index 33f37c33ba6..519bb80f044 100644 --- a/apps/sim/executor/utils/vertex-credential.ts +++ b/apps/sim/executor/utils/vertex-credential.ts @@ -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') @@ -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') } diff --git a/apps/sim/lib/auth/credential-access.test.ts b/apps/sim/lib/auth/credential-access.test.ts new file mode 100644 index 00000000000..4a30c270b64 --- /dev/null +++ b/apps/sim/lib/auth/credential-access.test.ts @@ -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, + membership: { role: string }[] = [] +) { + queueTableRows(credential, [credentialRow]) + queueTableRows(credentialMember, membership) +} + +/** The rows `resolveCredentialTokenIdentity` reads: the credential, then its account. */ +function queueTokenIdentity( + credentialRow: Record | 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') + }) + }) +}) diff --git a/apps/sim/lib/auth/credential-access.ts b/apps/sim/lib/auth/credential-access.ts index 5dcddf5d45f..125511b67a8 100644 --- a/apps/sim/lib/auth/credential-access.ts +++ b/apps/sim/lib/auth/credential-access.ts @@ -1,9 +1,14 @@ import { db } from '@sim/db' -import { account, credential, credentialMember, workflow as workflowTable } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' +import { account, credential, workflow as workflowTable } from '@sim/db/schema' +import { and, asc, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + type CredentialActorContext, + canUseCredential, + getCredentialActorContext, + resolveCredentialTokenIdentity, +} from '@/lib/credentials/access' export interface CredentialAccessResult { ok: boolean @@ -16,11 +21,34 @@ export interface CredentialAccessResult { credentialType?: 'oauth' | 'service_account' } +const NO_CREDENTIAL_ACCESS = + 'You do not have access to this credential. Ask the credential admin to add you as a member.' +const NO_WORKSPACE_ACCESS = 'You do not have access to this workspace.' + +/** + * Maps the canonical use rule (`canUseCredential`) onto the actionable message each + * denial deserves, so every surface authorizing a credential applies one predicate + * and only the wording is local to this module. + */ +function credentialAccessError(access: CredentialActorContext): string | null { + if (!access.credential) return 'Credential not found' + if (!access.hasWorkspaceAccess) return NO_WORKSPACE_ACCESS + if (!canUseCredential(access)) return NO_CREDENTIAL_ACCESS + return null +} + /** * Centralizes auth + credential membership checks for OAuth usage. - * - Workspace-scoped credential IDs enforce active credential_member access. - * - Legacy account IDs are resolved to workspace-scoped credentials when workflowId is provided. - * - Direct legacy account-ID access without workflowId is restricted to account owners only. + * + * Every workspace-scoped credential — whether addressed by its `credential.id` or + * by the legacy `account.id` it wraps — resolves to the same rule: active credential + * membership, or derived credential admin. A `workflowId`, when supplied, only pins + * which workspace a legacy account id is resolved through; it never grants access on + * its own, so surfaces without a workflow (knowledge base connectors, credential + * management) authorize identically to workflow surfaces. + * + * Raw account ids that belong to no workspace credential at all remain private to + * their owner. */ export async function authorizeCredentialUse( request: NextRequest, @@ -49,69 +77,41 @@ export async function authorizeCredentialUse( } const actingUserId = auth.userId - - const [workflowContext] = workflowId - ? await db - .select({ workspaceId: workflowTable.workspaceId }) - .from(workflowTable) - .where(eq(workflowTable.id, workflowId)) - .limit(1) - : [null] - - if (workflowId && (!workflowContext || !workflowContext.workspaceId)) { + const authType = auth.authType as CredentialAccessResult['authType'] + + const [workflowRows, platformAccess] = await Promise.all([ + workflowId + ? db + .select({ workspaceId: workflowTable.workspaceId }) + .from(workflowTable) + .where(eq(workflowTable.id, workflowId)) + .limit(1) + : Promise.resolve([]), + getCredentialActorContext(credentialId, actingUserId), + ]) + + const workflowContext = workflowRows[0] ?? null + + if (workflowId && !workflowContext?.workspaceId) { return { ok: false, error: 'Workflow not found' } } - const [platformCredential] = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - type: credential.type, - accountId: credential.accountId, - }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) + const scopeWorkspaceId = workflowContext?.workspaceId ?? null + const platformCredential = platformAccess.credential if (platformCredential) { - if (platformCredential.type === 'service_account') { - if (workflowContext && workflowContext.workspaceId !== platformCredential.workspaceId) { - return { ok: false, error: 'Credential is not accessible from this workflow workspace' } - } - - const requesterPerm = await getUserEntityPermissions( - actingUserId, - 'workspace', - platformCredential.workspaceId - ) + if (scopeWorkspaceId && scopeWorkspaceId !== platformCredential.workspaceId) { + return { ok: false, error: 'Credential is not accessible from this workflow workspace' } + } - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, platformCredential.id), - eq(credentialMember.userId, actingUserId), - eq(credentialMember.status, 'active') - ) - ) - .limit(1) - - if (requesterPerm === null) { - return { ok: false, error: 'You do not have access to this workspace.' } - } - if (!membership && requesterPerm !== 'admin') { - return { - ok: false, - error: - 'You do not have access to this credential. Ask the credential admin to add you as a member.', - } - } + const accessError = credentialAccessError(platformAccess) + if (accessError) return { ok: false, error: accessError } + if (platformCredential.type === 'service_account') { return { ok: true, - authType: auth.authType as CredentialAccessResult['authType'], - requesterUserId: auth.userId, + authType, + requesterUserId: actingUserId, credentialOwnerUserId: actingUserId, workspaceId: platformCredential.workspaceId, resolvedCredentialId: platformCredential.id, @@ -123,149 +123,80 @@ export async function authorizeCredentialUse( return { ok: false, error: 'Unsupported credential type for OAuth access' } } - if (workflowContext && workflowContext.workspaceId !== platformCredential.workspaceId) { - return { ok: false, error: 'Credential is not accessible from this workflow workspace' } - } - - const [accountRow] = await db - .select({ userId: account.userId }) - .from(account) - .where(eq(account.id, platformCredential.accountId)) - .limit(1) - - if (!accountRow) { - return { ok: false, error: 'Credential account not found' } - } - - const requesterPerm = await getUserEntityPermissions( - actingUserId, - 'workspace', - platformCredential.workspaceId - ) - - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, platformCredential.id), - eq(credentialMember.userId, actingUserId), - eq(credentialMember.status, 'active') - ) - ) - .limit(1) - - if (requesterPerm === null) { - return { - ok: false, - error: 'You do not have access to this workspace.', - } - } - if (!membership && requesterPerm !== 'admin') { - return { - ok: false, - error: `You do not have access to this credential. Ask the credential admin to add you as a member.`, - } - } - - const ownerPerm = await getUserEntityPermissions( - accountRow.userId, - 'workspace', + const identity = await resolveCredentialTokenIdentity( + platformCredential.id, platformCredential.workspaceId ) - if (ownerPerm === null) { - return { ok: false, error: 'Unauthorized' } - } + if (identity?.kind !== 'oauth') return { ok: false, error: 'Unauthorized' } return { ok: true, - authType: auth.authType as CredentialAccessResult['authType'], - requesterUserId: auth.userId, - credentialOwnerUserId: accountRow.userId, + authType, + requesterUserId: actingUserId, + credentialOwnerUserId: identity.userId, workspaceId: platformCredential.workspaceId, resolvedCredentialId: platformCredential.accountId, credentialType: 'oauth', } } - if (workflowContext?.workspaceId) { - const [workspaceCredential] = await db - .select({ - id: credential.id, - workspaceId: credential.workspaceId, - accountId: credential.accountId, - }) - .from(credential) - .where( - and( - eq(credential.type, 'oauth'), - eq(credential.workspaceId, workflowContext.workspaceId), - eq(credential.accountId, credentialId) - ) - ) - .limit(1) - - if (!workspaceCredential?.accountId) { - return { ok: false, error: 'Credential not found' } - } - - const [accountRow] = await db - .select({ userId: account.userId }) - .from(account) - .where(eq(account.id, workspaceCredential.accountId)) - .limit(1) - - if (!accountRow) { - return { ok: false, error: 'Credential account not found' } - } - - const [membership] = await db - .select({ id: credentialMember.id }) - .from(credentialMember) - .where( - and( - eq(credentialMember.credentialId, workspaceCredential.id), - eq(credentialMember.userId, actingUserId), - eq(credentialMember.status, 'active') - ) + /** + * Credentials predating the workspace-scoped `credential` table are addressed by + * raw account id. Each workspace that shares the account has its own credential + * row wrapping it, so authorization runs against the rows the caller can reach — + * pinned to the workflow's workspace when one was supplied. + */ + const workspaceCredentials = await db + .select({ id: credential.id, workspaceId: credential.workspaceId }) + .from(credential) + .where( + and( + eq(credential.type, 'oauth'), + eq(credential.accountId, credentialId), + scopeWorkspaceId ? eq(credential.workspaceId, scopeWorkspaceId) : undefined ) - .limit(1) + ) + .orderBy(asc(credential.createdAt)) - if (!membership) { - const requesterPerm = await getUserEntityPermissions( - actingUserId, - 'workspace', - workflowContext.workspaceId - ) - if (requesterPerm !== 'admin') { - return { - ok: false, - error: - 'You do not have access to this credential. Ask the credential admin to add you as a member.', - } - } + let firstRejection: string | null = null + for (const workspaceCredential of workspaceCredentials) { + const accessError = credentialAccessError( + await getCredentialActorContext(workspaceCredential.id, actingUserId) + ) + if (accessError) { + firstRejection ??= accessError + continue } - const ownerPerm = await getUserEntityPermissions( - accountRow.userId, - 'workspace', - workflowContext.workspaceId + const identity = await resolveCredentialTokenIdentity( + credentialId, + workspaceCredential.workspaceId ) - if (ownerPerm === null) { - return { ok: false, error: 'Unauthorized' } + if (identity?.kind !== 'oauth') { + firstRejection ??= 'Unauthorized' + continue } return { ok: true, - authType: auth.authType as CredentialAccessResult['authType'], - requesterUserId: auth.userId, - credentialOwnerUserId: accountRow.userId, - workspaceId: workflowContext.workspaceId, - resolvedCredentialId: workspaceCredential.accountId, + authType, + requesterUserId: actingUserId, + credentialOwnerUserId: identity.userId, + workspaceId: workspaceCredential.workspaceId, + resolvedCredentialId: credentialId, credentialType: 'oauth', } } + /** + * A workflow pins the credential to that workflow's workspace, so an account that + * resolves to no reachable credential row there is out of scope — it must not fall + * through to the owner-only path and cross the workspace boundary. + */ + if (scopeWorkspaceId) { + return { ok: false, error: firstRejection ?? 'Credential not found' } + } + const [legacyAccount] = await db .select({ userId: account.userId }) .from(account) @@ -280,14 +211,14 @@ export async function authorizeCredentialUse( return { ok: false, error: 'workflowId is required' } } - if (auth.userId !== legacyAccount.userId) { - return { ok: false, error: 'Unauthorized' } + if (actingUserId !== legacyAccount.userId) { + return { ok: false, error: firstRejection ?? 'Unauthorized' } } return { ok: true, - authType: auth.authType as CredentialAccessResult['authType'], - requesterUserId: auth.userId, + authType, + requesterUserId: actingUserId, credentialOwnerUserId: legacyAccount.userId, resolvedCredentialId: credentialId, credentialType: 'oauth', diff --git a/apps/sim/lib/credentials/access.ts b/apps/sim/lib/credentials/access.ts index 185d6206db6..9a149357a55 100644 --- a/apps/sim/lib/credentials/access.ts +++ b/apps/sim/lib/credentials/access.ts @@ -112,6 +112,18 @@ export interface CredentialActorContext { isAdmin: boolean } +/** + * Whether a user may *use* a credential: they still have access to its workspace + * and are either an active member or a derived credential admin. + * + * Deliberately distinct from the admin-only rule that governs *managing* a + * credential (rename, delete, membership changes) — that one has no member + * fallback. Do not fold the two together. + */ +export function canUseCredential(access: CredentialActorContext): boolean { + return access.hasWorkspaceAccess && (Boolean(access.member) || access.isAdmin) +} + /** * Resolves user access context for a credential. Pass `workspaceAccess` when the * caller has already resolved access for the credential's workspace to skip a