mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
v0.3.26: fix billing, bubble up workflow block errors, credentials security improvements
v0.3.26: fix billing, bubble up workflow block errors, credentials security improvements
This commit is contained in:
@@ -10,6 +10,8 @@ describe('OAuth Token API Routes', () => {
|
||||
const mockGetUserId = vi.fn()
|
||||
const mockGetCredential = vi.fn()
|
||||
const mockRefreshTokenIfNeeded = vi.fn()
|
||||
const mockAuthorizeCredentialUse = vi.fn()
|
||||
const mockCheckHybridAuth = vi.fn()
|
||||
|
||||
const mockLogger = {
|
||||
info: vi.fn(),
|
||||
@@ -37,6 +39,14 @@ describe('OAuth Token API Routes', () => {
|
||||
vi.doMock('@/lib/logs/console/logger', () => ({
|
||||
createLogger: vi.fn().mockReturnValue(mockLogger),
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/auth/credential-access', () => ({
|
||||
authorizeCredentialUse: mockAuthorizeCredentialUse,
|
||||
}))
|
||||
|
||||
vi.doMock('@/lib/auth/hybrid', () => ({
|
||||
checkHybridAuth: mockCheckHybridAuth,
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -48,7 +58,12 @@ describe('OAuth Token API Routes', () => {
|
||||
*/
|
||||
describe('POST handler', () => {
|
||||
it('should return access token successfully', async () => {
|
||||
mockGetUserId.mockResolvedValueOnce('test-user-id')
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
authType: 'session',
|
||||
requesterUserId: 'test-user-id',
|
||||
credentialOwnerUserId: 'owner-user-id',
|
||||
})
|
||||
mockGetCredential.mockResolvedValueOnce({
|
||||
id: 'credential-id',
|
||||
accessToken: 'test-token',
|
||||
@@ -78,14 +93,18 @@ describe('OAuth Token API Routes', () => {
|
||||
expect(data).toHaveProperty('accessToken', 'fresh-token')
|
||||
|
||||
// Verify mocks were called correctly
|
||||
// POST no longer calls getUserId; token resolution uses credential owner.
|
||||
expect(mockGetUserId).not.toHaveBeenCalled()
|
||||
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
|
||||
expect(mockGetCredential).toHaveBeenCalled()
|
||||
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle workflowId for server-side authentication', async () => {
|
||||
mockGetUserId.mockResolvedValueOnce('workflow-owner-id')
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
authType: 'internal_jwt',
|
||||
requesterUserId: 'workflow-owner-id',
|
||||
credentialOwnerUserId: 'workflow-owner-id',
|
||||
})
|
||||
mockGetCredential.mockResolvedValueOnce({
|
||||
id: 'credential-id',
|
||||
accessToken: 'test-token',
|
||||
@@ -111,8 +130,7 @@ describe('OAuth Token API Routes', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('accessToken', 'fresh-token')
|
||||
|
||||
// POST no longer calls getUserId; still refreshes successfully
|
||||
expect(mockGetUserId).not.toHaveBeenCalled()
|
||||
expect(mockAuthorizeCredentialUse).toHaveBeenCalled()
|
||||
expect(mockGetCredential).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -130,8 +148,10 @@ describe('OAuth Token API Routes', () => {
|
||||
})
|
||||
|
||||
it('should handle authentication failure', async () => {
|
||||
// Authentication failure no longer applies to POST path; treat as refresh failure via missing owner
|
||||
mockGetUserId.mockResolvedValueOnce(undefined)
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
error: 'Authentication required',
|
||||
})
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
credentialId: 'credential-id',
|
||||
@@ -142,12 +162,12 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect([401, 404]).toContain(response.status)
|
||||
expect(response.status).toBe(403)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle workflow not found', async () => {
|
||||
mockGetUserId.mockResolvedValueOnce(undefined)
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({ ok: false, error: 'Workflow not found' })
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
credentialId: 'credential-id',
|
||||
@@ -159,13 +179,16 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
// With owner-based resolution, missing workflowId no longer matters.
|
||||
// If credential not found via owner lookup, returns 404 accordingly
|
||||
expect([401, 404]).toContain(response.status)
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
it('should handle credential not found', async () => {
|
||||
mockGetUserId.mockResolvedValueOnce('test-user-id')
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
authType: 'session',
|
||||
requesterUserId: 'test-user-id',
|
||||
credentialOwnerUserId: 'owner-user-id',
|
||||
})
|
||||
mockGetCredential.mockResolvedValueOnce(undefined)
|
||||
|
||||
const req = createMockRequest('POST', {
|
||||
@@ -177,12 +200,17 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await POST(req)
|
||||
const data = await response.json()
|
||||
|
||||
expect([401, 404]).toContain(response.status)
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle token refresh failure', async () => {
|
||||
mockGetUserId.mockResolvedValueOnce('test-user-id')
|
||||
mockAuthorizeCredentialUse.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
authType: 'session',
|
||||
requesterUserId: 'test-user-id',
|
||||
credentialOwnerUserId: 'owner-user-id',
|
||||
})
|
||||
mockGetCredential.mockResolvedValueOnce({
|
||||
id: 'credential-id',
|
||||
accessToken: 'test-token',
|
||||
@@ -211,7 +239,11 @@ describe('OAuth Token API Routes', () => {
|
||||
*/
|
||||
describe('GET handler', () => {
|
||||
it('should return access token successfully', async () => {
|
||||
mockGetUserId.mockResolvedValueOnce('test-user-id')
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
authType: 'session',
|
||||
userId: 'test-user-id',
|
||||
})
|
||||
mockGetCredential.mockResolvedValueOnce({
|
||||
id: 'credential-id',
|
||||
accessToken: 'test-token',
|
||||
@@ -236,7 +268,7 @@ describe('OAuth Token API Routes', () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect(data).toHaveProperty('accessToken', 'fresh-token')
|
||||
|
||||
expect(mockGetUserId).toHaveBeenCalledWith(mockRequestId)
|
||||
expect(mockCheckHybridAuth).toHaveBeenCalled()
|
||||
expect(mockGetCredential).toHaveBeenCalledWith(mockRequestId, 'credential-id', 'test-user-id')
|
||||
expect(mockRefreshTokenIfNeeded).toHaveBeenCalled()
|
||||
})
|
||||
@@ -255,7 +287,10 @@ describe('OAuth Token API Routes', () => {
|
||||
})
|
||||
|
||||
it('should handle authentication failure', async () => {
|
||||
mockGetUserId.mockResolvedValueOnce(undefined)
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({
|
||||
success: false,
|
||||
error: 'Authentication required',
|
||||
})
|
||||
|
||||
const req = new Request(
|
||||
'http://localhost:3000/api/auth/oauth/token?credentialId=credential-id'
|
||||
@@ -266,12 +301,16 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await GET(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect([401, 404]).toContain(response.status)
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle credential not found', async () => {
|
||||
mockGetUserId.mockResolvedValueOnce('test-user-id')
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
authType: 'session',
|
||||
userId: 'test-user-id',
|
||||
})
|
||||
mockGetCredential.mockResolvedValueOnce(undefined)
|
||||
|
||||
const req = new Request(
|
||||
@@ -283,12 +322,16 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await GET(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect([401, 404]).toContain(response.status)
|
||||
expect(response.status).toBe(404)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle missing access token', async () => {
|
||||
mockGetUserId.mockResolvedValueOnce('test-user-id')
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
authType: 'session',
|
||||
userId: 'test-user-id',
|
||||
})
|
||||
mockGetCredential.mockResolvedValueOnce({
|
||||
id: 'credential-id',
|
||||
accessToken: null,
|
||||
@@ -305,12 +348,16 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await GET(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect([400, 401]).toContain(response.status)
|
||||
expect(response.status).toBe(400)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
|
||||
it('should handle token refresh failure', async () => {
|
||||
mockGetUserId.mockResolvedValueOnce('test-user-id')
|
||||
mockCheckHybridAuth.mockResolvedValueOnce({
|
||||
success: true,
|
||||
authType: 'session',
|
||||
userId: 'test-user-id',
|
||||
})
|
||||
mockGetCredential.mockResolvedValueOnce({
|
||||
id: 'credential-id',
|
||||
accessToken: 'test-token',
|
||||
@@ -329,7 +376,7 @@ describe('OAuth Token API Routes', () => {
|
||||
const response = await GET(req as any)
|
||||
const data = await response.json()
|
||||
|
||||
expect([401, 404]).toContain(response.status)
|
||||
expect(response.status).toBe(401)
|
||||
expect(data).toHaveProperty('error')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { getCredential, getUserId, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
import { getCredential, refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -29,18 +28,13 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Resolve the credential owner directly by id. This lets API/UI/webhooks run under
|
||||
// whichever user owns the persisted credential, not necessarily the session user
|
||||
// or workflow owner.
|
||||
const creds = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
if (!creds.length) {
|
||||
logger.error(`[${requestId}] Credential not found: ${credentialId}`)
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
const credentialOwnerUserId = creds[0].userId
|
||||
|
||||
// Fetch the credential verifying it belongs to the resolved owner
|
||||
const credential = await getCredential(requestId, credentialId, credentialOwnerUserId)
|
||||
// Fetch the credential as the owner to enforce ownership scoping
|
||||
const credential = await getCredential(requestId, credentialId, authz.credentialOwnerUserId)
|
||||
|
||||
try {
|
||||
// Refresh the token if needed
|
||||
@@ -73,14 +67,13 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// For GET requests, we only support session-based authentication
|
||||
const userId = await getUserId(requestId)
|
||||
|
||||
if (!userId) {
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: false })
|
||||
if (!auth.success || auth.authType !== 'session' || !auth.userId) {
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credential = await getCredential(requestId, credentialId, userId)
|
||||
const credential = await getCredential(requestId, credentialId, auth.userId)
|
||||
|
||||
if (!credential) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -18,46 +15,28 @@ export async function GET(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Google Drive file request received`)
|
||||
|
||||
try {
|
||||
// Get the session
|
||||
const session = await getSession()
|
||||
|
||||
// Check if the user is authenticated
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthenticated request rejected`)
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the credential ID and file ID from the query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const fileId = searchParams.get('fileId')
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
|
||||
if (!credentialId || !fileId) {
|
||||
logger.warn(`[${requestId}] Missing required parameters`)
|
||||
return NextResponse.json({ error: 'Credential ID and File ID are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
// Credential ownership:
|
||||
// - If session user owns the credential: allow
|
||||
// - If not, allow read-only resolution when a workflowId is present (collaboration case)
|
||||
if (credential.userId !== session.user.id && !workflowId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
const authz = await authorizeCredentialUse(request, { credentialId: credentialId, workflowId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const ownerUserId = credential.userId
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -28,48 +25,26 @@ export async function GET(request: NextRequest) {
|
||||
logger.info(`[${requestId}] Google Calendar calendars request received`)
|
||||
|
||||
try {
|
||||
// Get the session
|
||||
const session = await getSession()
|
||||
|
||||
// Check if the user is authenticated
|
||||
if (!session?.user?.id) {
|
||||
logger.warn(`[${requestId}] Unauthenticated request rejected`)
|
||||
return NextResponse.json({ error: 'User not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Get the credential ID from the query params
|
||||
const { searchParams } = new URL(request.url)
|
||||
const credentialId = searchParams.get('credentialId')
|
||||
const workflowId = searchParams.get('workflowId')
|
||||
const workflowId = searchParams.get('workflowId') || undefined
|
||||
|
||||
if (!credentialId) {
|
||||
logger.warn(`[${requestId}] Missing credentialId parameter`)
|
||||
return NextResponse.json({ error: 'Credential ID is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get the credential from the database
|
||||
const credentials = await db.select().from(account).where(eq(account.id, credentialId)).limit(1)
|
||||
|
||||
if (!credentials.length) {
|
||||
logger.warn(`[${requestId}] Credential not found`, { credentialId })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const credential = credentials[0]
|
||||
|
||||
// Allow collaborator read when workflowId present; otherwise require ownership
|
||||
const ownerUserId = credential.userId
|
||||
const requesterUserId = session.user.id
|
||||
if (ownerUserId !== requesterUserId && !workflowId) {
|
||||
logger.warn(`[${requestId}] Unauthorized credential access attempt`, {
|
||||
credentialUserId: ownerUserId,
|
||||
requestUserId: requesterUserId,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
const authz = await authorizeCredentialUse(request, { credentialId, workflowId })
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Refresh access token if needed using the utility function
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credentialId, ownerUserId, requestId)
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credentialId,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ error: 'Failed to obtain valid access token' }, { status: 401 })
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { Project } from '@linear/sdk'
|
||||
import { LinearClient } from '@linear/sdk'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -14,7 +11,6 @@ const logger = createLogger('LinearProjectsAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const body = await request.json()
|
||||
const { credential, teamId, workflowId } = body
|
||||
|
||||
@@ -23,38 +19,24 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Credential and teamId are required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const requesterUserId = session?.user?.id || ''
|
||||
if (!requesterUserId) {
|
||||
logger.error('No user ID found in session')
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Look up credential owner
|
||||
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
|
||||
if (!creds.length) {
|
||||
logger.error('Credential not found for Linear API', { credential })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const credentialOwnerUserId = creds[0].userId
|
||||
|
||||
// If requester does not own the credential, allow only if workflowId present (collab context)
|
||||
if (credentialOwnerUserId !== requesterUserId && !workflowId) {
|
||||
logger.warn('Unauthorized Linear credential access attempt without workflow context', {
|
||||
credentialOwnerUserId,
|
||||
requesterUserId,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
credentialOwnerUserId,
|
||||
workflowId || 'linear'
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: credentialOwnerUserId,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import type { Team } from '@linear/sdk'
|
||||
import { LinearClient } from '@linear/sdk'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -14,7 +11,7 @@ const logger = createLogger('LinearTeamsAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
@@ -23,38 +20,23 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Credential is required' }, { status: 400 })
|
||||
}
|
||||
|
||||
const requesterUserId = session?.user?.id || ''
|
||||
if (!requesterUserId) {
|
||||
logger.error('No user ID found in session')
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
}
|
||||
|
||||
// Look up credential to determine owner
|
||||
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
|
||||
if (!creds.length) {
|
||||
logger.error('Credential not found for Linear API', { credential })
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const credentialOwnerUserId = creds[0].userId
|
||||
|
||||
// If requester does not own the credential, allow only if workflowId present (collab context)
|
||||
if (credentialOwnerUserId !== requesterUserId && !workflowId) {
|
||||
logger.warn('Unauthorized Linear credential access attempt without workflow context', {
|
||||
credentialOwnerUserId,
|
||||
requesterUserId,
|
||||
})
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
credentialOwnerUserId,
|
||||
workflowId || 'linear'
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: credentialOwnerUserId,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -12,7 +9,6 @@ const logger = createLogger('TeamsChannelsAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const body = await request.json()
|
||||
|
||||
const { credential, teamId, workflowId } = body
|
||||
@@ -28,28 +24,24 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = session?.user?.id || ''
|
||||
if (!userId) {
|
||||
logger.error('No user ID found in session')
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
|
||||
if (!creds.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const ownerUserId = creds[0].userId
|
||||
if (ownerUserId !== userId && !workflowId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
// Allow read-only resolution when a workflowId is present
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
ownerUserId,
|
||||
authz.credentialOwnerUserId,
|
||||
'TeamsChannelsAPI'
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', { credentialId: credential, userId })
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -118,7 +115,7 @@ const getChatDisplayName = async (
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const body = await request.json()
|
||||
|
||||
const { credential, workflowId } = body
|
||||
@@ -129,21 +126,24 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = session?.user?.id || ''
|
||||
if (!userId) {
|
||||
logger.error('No user ID found in session')
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
|
||||
if (!creds.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const ownerUserId = creds[0].userId
|
||||
// Allow read-only resolution when a workflowId is present
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credential, ownerUserId, 'TeamsChatsAPI')
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
'TeamsChatsAPI'
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', { credentialId: credential, userId })
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json({ error: 'Could not retrieve access token' }, { status: 401 })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
import { db } from '@/db'
|
||||
import { account } from '@/db/schema'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
@@ -12,7 +9,6 @@ const logger = createLogger('TeamsTeamsAPI')
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const body = await request.json()
|
||||
|
||||
const { credential, workflowId } = body
|
||||
@@ -23,27 +19,26 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = session?.user?.id || ''
|
||||
if (!userId) {
|
||||
logger.error('No user ID found in session')
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
// Resolve credential owner
|
||||
const creds = await db.select().from(account).where(eq(account.id, credential)).limit(1)
|
||||
if (!creds.length) {
|
||||
return NextResponse.json({ error: 'Credential not found' }, { status: 404 })
|
||||
}
|
||||
const ownerUserId = creds[0].userId
|
||||
// If session doesn't own it and no workflow context, reject
|
||||
if (ownerUserId !== userId && !workflowId) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
// Allow read-only resolution when a workflowId is present, even if session user isn't the owner
|
||||
|
||||
const accessToken = await refreshAccessTokenIfNeeded(credential, ownerUserId, 'TeamsTeamsAPI')
|
||||
const accessToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
'TeamsTeamsAPI'
|
||||
)
|
||||
|
||||
if (!accessToken) {
|
||||
logger.error('Failed to get access token', { credentialId: credential, userId })
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getSession } from '@/lib/auth'
|
||||
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils'
|
||||
|
||||
@@ -17,7 +17,7 @@ interface SlackChannel {
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await getSession()
|
||||
const requestId = crypto.randomUUID().slice(0, 8)
|
||||
const body = await request.json()
|
||||
const { credential, workflowId } = body
|
||||
|
||||
@@ -34,15 +34,23 @@ export async function POST(request: Request) {
|
||||
isBotToken = true
|
||||
logger.info('Using direct bot token for Slack API')
|
||||
} else {
|
||||
const userId = session?.user?.id || ''
|
||||
if (!userId) {
|
||||
logger.error('No user ID found in session')
|
||||
return NextResponse.json({ error: 'Authentication required' }, { status: 401 })
|
||||
const authz = await authorizeCredentialUse(request as any, {
|
||||
credentialId: credential,
|
||||
workflowId,
|
||||
})
|
||||
if (!authz.ok || !authz.credentialOwnerUserId) {
|
||||
return NextResponse.json({ error: authz.error || 'Unauthorized' }, { status: 403 })
|
||||
}
|
||||
|
||||
const resolvedToken = await refreshAccessTokenIfNeeded(credential, userId, workflowId)
|
||||
const resolvedToken = await refreshAccessTokenIfNeeded(
|
||||
credential,
|
||||
authz.credentialOwnerUserId,
|
||||
requestId
|
||||
)
|
||||
if (!resolvedToken) {
|
||||
logger.error('Failed to get access token', { credentialId: credential, userId })
|
||||
logger.error('Failed to get access token', {
|
||||
credentialId: credential,
|
||||
userId: authz.credentialOwnerUserId,
|
||||
})
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Could not retrieve access token',
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
UserCircle,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useSubscriptionStore } from '@/stores/subscription/store'
|
||||
|
||||
@@ -98,6 +98,9 @@ export function SettingsNavigation({
|
||||
const { getSubscriptionStatus } = useSubscriptionStore()
|
||||
const subscription = getSubscriptionStatus()
|
||||
|
||||
// Get billing status
|
||||
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
|
||||
|
||||
const navigationItems = allNavigationItems.filter((item) => {
|
||||
if (item.hideWhenBillingDisabled && !isBillingEnabled) {
|
||||
return false
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
@@ -44,6 +44,9 @@ export function SettingsModal({ open, onOpenChange }: SettingsModalProps) {
|
||||
const { activeOrganization } = useOrganizationStore()
|
||||
const hasLoadedInitialData = useRef(false)
|
||||
|
||||
// Get billing status
|
||||
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
|
||||
|
||||
useEffect(() => {
|
||||
async function loadAllSettings() {
|
||||
if (!open) return
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HelpCircle, LibraryBig, ScrollText, Search, Settings, Shapes } from 'lu
|
||||
import { useParams, usePathname, useRouter } from 'next/navigation'
|
||||
import { Button, ScrollArea, Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui'
|
||||
import { useSession } from '@/lib/auth-client'
|
||||
import { isBillingEnabled } from '@/lib/environment'
|
||||
import { getEnv } from '@/lib/env'
|
||||
import { createLogger } from '@/lib/logs/console/logger'
|
||||
import { generateWorkspaceName } from '@/lib/naming'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -195,6 +195,9 @@ export function Sidebar() {
|
||||
const userPermissions = useUserPermissionsContext()
|
||||
const isLoading = workflowsLoading || sessionLoading
|
||||
|
||||
// Get billing status
|
||||
const isBillingEnabled = getEnv('NEXT_PUBLIC_BILLING_ENABLED') || false
|
||||
|
||||
// Add state to prevent multiple simultaneous workflow creations
|
||||
const [isCreatingWorkflow, setIsCreatingWorkflow] = useState(false)
|
||||
// Add state to prevent multiple simultaneous workspace creations
|
||||
|
||||
@@ -111,13 +111,9 @@ describe('WorkflowBlockHandler', () => {
|
||||
'parent-workflow-id_sub_child-workflow-id_workflow-block-1'
|
||||
)
|
||||
|
||||
const result = await handler.execute(mockBlock, inputs, mockContext)
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error:
|
||||
'Cyclic workflow dependency detected: parent-workflow-id_sub_child-workflow-id_workflow-block-1',
|
||||
childWorkflowName: 'child-workflow-id',
|
||||
})
|
||||
await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow(
|
||||
'Error in child workflow "child-workflow-id": Cyclic workflow dependency detected: parent-workflow-id_sub_child-workflow-id_workflow-block-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('should enforce maximum depth limit', async () => {
|
||||
@@ -130,12 +126,9 @@ describe('WorkflowBlockHandler', () => {
|
||||
'level1_sub_level2_sub_level3_sub_level4_sub_level5_sub_level6_sub_level7_sub_level8_sub_level9_sub_level10_sub_level11',
|
||||
}
|
||||
|
||||
const result = await handler.execute(mockBlock, inputs, deepContext)
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Maximum workflow nesting depth of 10 exceeded',
|
||||
childWorkflowName: 'child-workflow-id',
|
||||
})
|
||||
await expect(handler.execute(mockBlock, inputs, deepContext)).rejects.toThrow(
|
||||
'Error in child workflow "child-workflow-id": Maximum workflow nesting depth of 10 exceeded'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle child workflow not found', async () => {
|
||||
@@ -147,12 +140,9 @@ describe('WorkflowBlockHandler', () => {
|
||||
statusText: 'Not Found',
|
||||
})
|
||||
|
||||
const result = await handler.execute(mockBlock, inputs, mockContext)
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Child workflow non-existent-workflow not found',
|
||||
childWorkflowName: 'non-existent-workflow',
|
||||
})
|
||||
await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow(
|
||||
'Error in child workflow "non-existent-workflow": Child workflow non-existent-workflow not found'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle fetch errors gracefully', async () => {
|
||||
@@ -160,12 +150,9 @@ describe('WorkflowBlockHandler', () => {
|
||||
|
||||
mockFetch.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await handler.execute(mockBlock, inputs, mockContext)
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'Child workflow child-workflow-id not found',
|
||||
childWorkflowName: 'child-workflow-id',
|
||||
})
|
||||
await expect(handler.execute(mockBlock, inputs, mockContext)).rejects.toThrow(
|
||||
'Error in child workflow "child-workflow-id": Child workflow child-workflow-id not found'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -115,6 +115,12 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
duration
|
||||
)
|
||||
|
||||
// If the child workflow failed, throw an error to trigger proper error handling in the parent
|
||||
if ((mappedResult as any).success === false) {
|
||||
const childError = (mappedResult as any).error || 'Unknown error'
|
||||
throw new Error(`Error in child workflow "${childWorkflowName}": ${childError}`)
|
||||
}
|
||||
|
||||
return mappedResult
|
||||
} catch (error: any) {
|
||||
logger.error(`Error executing child workflow ${workflowId}:`, error)
|
||||
@@ -128,11 +134,15 @@ export class WorkflowBlockHandler implements BlockHandler {
|
||||
const workflowMetadata = workflows[workflowId]
|
||||
const childWorkflowName = workflowMetadata?.name || workflowId
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error?.message || 'Child workflow execution failed',
|
||||
childWorkflowName,
|
||||
} as Record<string, any>
|
||||
// Enhance error message with child workflow context
|
||||
const originalError = error.message || 'Unknown error'
|
||||
|
||||
// Check if error message already has child workflow context to avoid duplication
|
||||
if (originalError.startsWith('Error in child workflow')) {
|
||||
throw error // Re-throw as-is to avoid duplication
|
||||
}
|
||||
|
||||
throw new Error(`Error in child workflow "${childWorkflowName}": ${originalError}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1448,7 +1448,7 @@ describe('Executor', () => {
|
||||
}
|
||||
)
|
||||
|
||||
it.concurrent('should surface child workflow failure in result without throwing', async () => {
|
||||
it.concurrent('should propagate errors from child workflows to parent workflow', async () => {
|
||||
const workflow = {
|
||||
version: '1.0',
|
||||
blocks: [
|
||||
@@ -1488,12 +1488,17 @@ describe('Executor', () => {
|
||||
|
||||
const result = await executor.execute('test-workflow-id')
|
||||
|
||||
// Verify that child workflow failure is surfaced in the overall result
|
||||
// Verify that child workflow errors propagate to parent
|
||||
expect(result).toBeDefined()
|
||||
if ('success' in result) {
|
||||
// With reverted behavior, parent execution may still be considered successful overall,
|
||||
// but the workflow block output should capture the failure. Only assert structure here.
|
||||
expect(typeof result.success).toBe('boolean')
|
||||
// The workflow should fail due to child workflow failure
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBeDefined()
|
||||
|
||||
// Error message should indicate it came from a child workflow
|
||||
if (result.error && typeof result.error === 'string') {
|
||||
expect(result.error).toContain('Error in child workflow')
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
113
apps/sim/lib/auth/credential-access.ts
Normal file
113
apps/sim/lib/auth/credential-access.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { checkHybridAuth } from '@/lib/auth/hybrid'
|
||||
import { getUserEntityPermissions } from '@/lib/permissions/utils'
|
||||
import { db } from '@/db'
|
||||
import { account, workflow as workflowTable } from '@/db/schema'
|
||||
|
||||
export interface CredentialAccessResult {
|
||||
ok: boolean
|
||||
error?: string
|
||||
authType?: 'session' | 'api_key' | 'internal_jwt'
|
||||
requesterUserId?: string
|
||||
credentialOwnerUserId?: string
|
||||
workspaceId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralizes auth + collaboration rules for credential use.
|
||||
* - Uses checkHybridAuth to authenticate the caller
|
||||
* - Fetches credential owner
|
||||
* - Authorization rules:
|
||||
* - session/api_key: allow if requester owns the credential; otherwise require workflowId and
|
||||
* verify BOTH requester and owner have access to the workflow's workspace
|
||||
* - internal_jwt: require workflowId (by default) and verify credential owner has access to the
|
||||
* workflow's workspace (requester identity is the system/workflow)
|
||||
*/
|
||||
export async function authorizeCredentialUse(
|
||||
request: NextRequest,
|
||||
params: { credentialId: string; workflowId?: string; requireWorkflowIdForInternal?: boolean }
|
||||
): Promise<CredentialAccessResult> {
|
||||
const { credentialId, workflowId, requireWorkflowIdForInternal = true } = params
|
||||
|
||||
const auth = await checkHybridAuth(request, { requireWorkflowId: requireWorkflowIdForInternal })
|
||||
if (!auth.success || !auth.userId) {
|
||||
return { ok: false, error: auth.error || 'Authentication required' }
|
||||
}
|
||||
|
||||
// Lookup credential owner
|
||||
const [credRow] = await db
|
||||
.select({ userId: account.userId })
|
||||
.from(account)
|
||||
.where(eq(account.id, credentialId))
|
||||
.limit(1)
|
||||
|
||||
if (!credRow) {
|
||||
return { ok: false, error: 'Credential not found' }
|
||||
}
|
||||
|
||||
const credentialOwnerUserId = credRow.userId
|
||||
|
||||
// If requester owns the credential, allow immediately
|
||||
if (auth.authType !== 'internal_jwt' && auth.userId === credentialOwnerUserId) {
|
||||
return {
|
||||
ok: true,
|
||||
authType: auth.authType,
|
||||
requesterUserId: auth.userId,
|
||||
credentialOwnerUserId,
|
||||
}
|
||||
}
|
||||
|
||||
// For collaboration paths, workflowId is required to scope to a workspace
|
||||
if (!workflowId) {
|
||||
return { ok: false, error: 'workflowId is required' }
|
||||
}
|
||||
|
||||
const [wf] = await db
|
||||
.select({ workspaceId: workflowTable.workspaceId })
|
||||
.from(workflowTable)
|
||||
.where(eq(workflowTable.id, workflowId))
|
||||
.limit(1)
|
||||
|
||||
if (!wf || !wf.workspaceId) {
|
||||
return { ok: false, error: 'Workflow not found' }
|
||||
}
|
||||
|
||||
if (auth.authType === 'internal_jwt') {
|
||||
// Internal calls: verify credential owner belongs to the workflow's workspace
|
||||
const ownerPerm = await getUserEntityPermissions(
|
||||
credentialOwnerUserId,
|
||||
'workspace',
|
||||
wf.workspaceId
|
||||
)
|
||||
if (ownerPerm === null) {
|
||||
return { ok: false, error: 'Unauthorized' }
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
authType: auth.authType,
|
||||
requesterUserId: auth.userId,
|
||||
credentialOwnerUserId,
|
||||
workspaceId: wf.workspaceId,
|
||||
}
|
||||
}
|
||||
|
||||
// Session/API key: verify BOTH requester and owner belong to the workflow's workspace
|
||||
const requesterPerm = await getUserEntityPermissions(auth.userId, 'workspace', wf.workspaceId)
|
||||
const ownerPerm = await getUserEntityPermissions(
|
||||
credentialOwnerUserId,
|
||||
'workspace',
|
||||
wf.workspaceId
|
||||
)
|
||||
if (requesterPerm === null || ownerPerm === null) {
|
||||
return { ok: false, error: 'Unauthorized' }
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
authType: auth.authType,
|
||||
requesterUserId: auth.userId,
|
||||
credentialOwnerUserId,
|
||||
workspaceId: wf.workspaceId,
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,9 @@ export const env = createEnv({
|
||||
|
||||
// Asset Storage
|
||||
NEXT_PUBLIC_BLOB_BASE_URL: z.string().url().optional(), // Base URL for Vercel Blob storage (CDN assets)
|
||||
|
||||
// Billing
|
||||
NEXT_PUBLIC_BILLING_ENABLED: z.boolean().optional(), // Enable billing enforcement and usage tracking (client-side)
|
||||
|
||||
// Google Services - For client-side Google integrations
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: z.string().optional(), // Google OAuth client ID for browser auth
|
||||
@@ -216,6 +219,7 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_VERCEL_URL: process.env.NEXT_PUBLIC_VERCEL_URL,
|
||||
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
|
||||
NEXT_PUBLIC_BLOB_BASE_URL: process.env.NEXT_PUBLIC_BLOB_BASE_URL,
|
||||
NEXT_PUBLIC_BILLING_ENABLED: process.env.NEXT_PUBLIC_BILLING_ENABLED,
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID,
|
||||
NEXT_PUBLIC_RB2B_KEY: process.env.NEXT_PUBLIC_RB2B_KEY,
|
||||
NEXT_PUBLIC_GOOGLE_API_KEY: process.env.NEXT_PUBLIC_GOOGLE_API_KEY,
|
||||
|
||||
@@ -183,8 +183,8 @@ export const appendTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsAppendRe
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
// Extract spreadsheet ID from the URL
|
||||
const urlParts = response.url.split('/spreadsheets/')
|
||||
// Extract spreadsheet ID from the URL (guard if url is missing)
|
||||
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
|
||||
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
|
||||
|
||||
// Create a simple metadata object with just the ID and URL
|
||||
|
||||
@@ -66,8 +66,8 @@ export const readTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsReadRespon
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
// Extract spreadsheet ID from the URL
|
||||
const urlParts = response.url.split('/spreadsheets/')
|
||||
// Extract spreadsheet ID from the URL (guard if url is missing)
|
||||
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
|
||||
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
|
||||
|
||||
// Create a simple metadata object with just the ID and URL
|
||||
|
||||
@@ -137,8 +137,8 @@ export const updateTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsUpdateRe
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
// Extract spreadsheet ID from the URL
|
||||
const urlParts = response.url.split('/spreadsheets/')
|
||||
// Extract spreadsheet ID from the URL (guard if url is missing)
|
||||
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
|
||||
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
|
||||
|
||||
// Create a simple metadata object with just the ID and URL
|
||||
|
||||
@@ -134,8 +134,8 @@ export const writeTool: ToolConfig<GoogleSheetsToolParams, GoogleSheetsWriteResp
|
||||
transformResponse: async (response: Response) => {
|
||||
const data = await response.json()
|
||||
|
||||
// Extract spreadsheet ID from the URL
|
||||
const urlParts = response.url.split('/spreadsheets/')
|
||||
// Extract spreadsheet ID from the URL (guard if url is missing)
|
||||
const urlParts = typeof response.url === 'string' ? response.url.split('/spreadsheets/') : []
|
||||
const spreadsheetId = urlParts[1]?.split('/')[0] || ''
|
||||
|
||||
// Create a simple metadata object with just the ID and URL
|
||||
|
||||
@@ -467,6 +467,8 @@ async function handleInternalRequest(
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
// Provide the resolved URL so tool transforms can safely read response.url
|
||||
url: fullUrl,
|
||||
json: async () => responseData,
|
||||
text: async () =>
|
||||
typeof responseData === 'string' ? responseData : JSON.stringify(responseData),
|
||||
|
||||
Reference in New Issue
Block a user