mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-30 17:05:18 +08:00
fix(credentials): authorize shared credentials without requiring a workflow (#6571)
`authorizeCredentialUse` could only reach its member-based sharing branch when a `workflowId` was supplied, so non-workflow surfaces — knowledge base connectors, credential management — fell through to an owner-only path and rejected everyone but the user who ran the OAuth flow. A workflow now only pins which workspace a legacy account id resolves through; it never grants access on its own. Access itself is decided by one rule everywhere: active credential member, or derived credential admin. - resolve legacy account ids through whichever workspace credential rows the caller can reach, instead of an owner-only fallback - extract `canUseCredential` and replace the predicate hand-inlined at five sites - reuse `resolveCredentialTokenIdentity` for owner resolution instead of a second local copy of the same invariant - keep the workflow-pinned path from crossing a workspace boundary
This commit is contained in:
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 authType = auth.authType as CredentialAccessResult['authType']
|
||||
|
||||
const [workflowContext] = workflowId
|
||||
? await db
|
||||
.select({ workspaceId: workflowTable.workspaceId })
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
: [null]
|
||||
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),
|
||||
])
|
||||
|
||||
if (workflowId && (!workflowContext || !workflowContext.workspaceId)) {
|
||||
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 (scopeWorkspaceId && scopeWorkspaceId !== platformCredential.workspaceId) {
|
||||
return { ok: false, error: 'Credential is not accessible from this workflow workspace' }
|
||||
}
|
||||
|
||||
const accessError = credentialAccessError(platformAccess)
|
||||
if (accessError) return { ok: false, error: accessError }
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
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.',
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
const identity = await resolveCredentialTokenIdentity(
|
||||
platformCredential.id,
|
||||
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',
|
||||
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)
|
||||
)
|
||||
/**
|
||||
* 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)
|
||||
|
||||
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')
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
|
||||
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.',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ownerPerm = await getUserEntityPermissions(
|
||||
accountRow.userId,
|
||||
'workspace',
|
||||
workflowContext.workspaceId
|
||||
)
|
||||
if (ownerPerm === null) {
|
||||
return { ok: false, error: 'Unauthorized' }
|
||||
.orderBy(asc(credential.createdAt))
|
||||
|
||||
let firstRejection: string | null = null
|
||||
for (const workspaceCredential of workspaceCredentials) {
|
||||
const accessError = credentialAccessError(
|
||||
await getCredentialActorContext(workspaceCredential.id, actingUserId)
|
||||
)
|
||||
if (accessError) {
|
||||
firstRejection ??= accessError
|
||||
continue
|
||||
}
|
||||
|
||||
const identity = await resolveCredentialTokenIdentity(
|
||||
credentialId,
|
||||
workspaceCredential.workspaceId
|
||||
)
|
||||
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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user