mirror of
https://github.com/simstudioai/sim.git
synced 2026-02-19 02:34:37 -05:00
* feat(audit-log): add persistent audit log system with comprehensive route instrumentation
* fix(audit-log): address PR review — nullable workspaceId, enum usage, remove redundant queries
- Make audit_log.workspace_id nullable with ON DELETE SET NULL (logs survive workspace/user deletion)
- Make audit_log.actor_id nullable with ON DELETE SET NULL
- Replace all 53 routes' string literal action/resourceType with AuditAction.X and AuditResourceType.X enums
- Fix empty workspaceId ('') → null for OAuth, form, and org routes to avoid FK violations
- Remove redundant DB queries in chat manage route (use checkChatAccess return data)
- Fix organization routes to pass workspaceId: null instead of organizationId
* fix(audit-log): replace remaining workspaceId '' fallbacks with null
* fix(audit-log): credential-set org IDs, workspace deletion FK, actorId fallback, string literal action
* reran migrations
* fix(mcp,audit): tighten env var domain bypass, add post-resolution check, form workspaceId
- Only bypass MCP domain check when env var is in hostname/authority, not path/query
- Add post-resolution validateMcpDomain call in test-connection endpoint
- Match client-side isDomainAllowed to same hostname-only bypass logic
- Return workspaceId from checkFormAccess, use in form audit logs
- Add 49 comprehensive domain-check tests covering all edge cases
* fix(mcp): stateful regex lastIndex bug, RFC 3986 authority parsing
- Remove /g flag from module-level ENV_VAR_PATTERN to avoid lastIndex state
- Create fresh regex instances per call in server-side hasEnvVarInHostname
- Fix authority extraction to terminate at /, ?, or # per RFC 3986
- Prevents bypass via https://evil.com?token={{SECRET}} (no path)
- Add test cases for query-only and fragment-only env var URLs (53 total)
* fix(audit-log): try/catch for never-throw contract, accept null actorName/Email, fix misleading action
- Wrap recordAudit body in try/catch so nanoid() or header extraction can't throw
- Accept string | null for actorName and actorEmail (session.user.name can be null)
- Normalize null -> undefined before insert to match DB column types
- Fix org members route: ORG_MEMBER_ADDED -> ORG_INVITATION_CREATED (sends invite, not adds member)
* improvement(audit-log): add resource names and specific invitation actions
* fix(audit-log): use validated chat record, add mock sync tests
232 lines
6.3 KiB
TypeScript
232 lines
6.3 KiB
TypeScript
import { db } from '@sim/db'
|
|
import { chat, workflow } from '@sim/db/schema'
|
|
import { createLogger } from '@sim/logger'
|
|
import { eq } from 'drizzle-orm'
|
|
import type { NextRequest, NextResponse } from 'next/server'
|
|
import {
|
|
isEmailAllowed,
|
|
setDeploymentAuthCookie,
|
|
validateAuthToken,
|
|
} from '@/lib/core/security/deployment'
|
|
import { decryptSecret } from '@/lib/core/security/encryption'
|
|
import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils'
|
|
|
|
const logger = createLogger('ChatAuthUtils')
|
|
|
|
export function setChatAuthCookie(
|
|
response: NextResponse,
|
|
chatId: string,
|
|
type: string,
|
|
encryptedPassword?: string | null
|
|
): void {
|
|
setDeploymentAuthCookie(response, 'chat', chatId, type, encryptedPassword)
|
|
}
|
|
|
|
/**
|
|
* Check if user has permission to create a chat for a specific workflow
|
|
*/
|
|
export async function checkWorkflowAccessForChatCreation(
|
|
workflowId: string,
|
|
userId: string
|
|
): Promise<{ hasAccess: boolean; workflow?: any }> {
|
|
const authorization = await authorizeWorkflowByWorkspacePermission({
|
|
workflowId,
|
|
userId,
|
|
action: 'admin',
|
|
})
|
|
|
|
if (!authorization.workflow) {
|
|
return { hasAccess: false }
|
|
}
|
|
|
|
if (authorization.allowed) {
|
|
return { hasAccess: true, workflow: authorization.workflow }
|
|
}
|
|
|
|
return { hasAccess: false }
|
|
}
|
|
|
|
/**
|
|
* Check if user has access to view/edit/delete a specific chat
|
|
*/
|
|
export async function checkChatAccess(
|
|
chatId: string,
|
|
userId: string
|
|
): Promise<{ hasAccess: boolean; chat?: any; workspaceId?: string }> {
|
|
const chatData = await db
|
|
.select({
|
|
chat: chat,
|
|
workflowWorkspaceId: workflow.workspaceId,
|
|
})
|
|
.from(chat)
|
|
.innerJoin(workflow, eq(chat.workflowId, workflow.id))
|
|
.where(eq(chat.id, chatId))
|
|
.limit(1)
|
|
|
|
if (chatData.length === 0) {
|
|
return { hasAccess: false }
|
|
}
|
|
|
|
const { chat: chatRecord, workflowWorkspaceId } = chatData[0]
|
|
if (!workflowWorkspaceId) {
|
|
return { hasAccess: false }
|
|
}
|
|
|
|
const authorization = await authorizeWorkflowByWorkspacePermission({
|
|
workflowId: chatRecord.workflowId,
|
|
userId,
|
|
action: 'admin',
|
|
})
|
|
|
|
return authorization.allowed
|
|
? { hasAccess: true, chat: chatRecord, workspaceId: workflowWorkspaceId }
|
|
: { hasAccess: false }
|
|
}
|
|
|
|
export async function validateChatAuth(
|
|
requestId: string,
|
|
deployment: any,
|
|
request: NextRequest,
|
|
parsedBody?: any
|
|
): Promise<{ authorized: boolean; error?: string }> {
|
|
const authType = deployment.authType || 'public'
|
|
|
|
if (authType === 'public') {
|
|
return { authorized: true }
|
|
}
|
|
|
|
const cookieName = `chat_auth_${deployment.id}`
|
|
const authCookie = request.cookies.get(cookieName)
|
|
|
|
if (authCookie && validateAuthToken(authCookie.value, deployment.id, deployment.password)) {
|
|
return { authorized: true }
|
|
}
|
|
|
|
if (authType === 'password') {
|
|
if (request.method === 'GET') {
|
|
return { authorized: false, error: 'auth_required_password' }
|
|
}
|
|
|
|
try {
|
|
if (!parsedBody) {
|
|
return { authorized: false, error: 'Password is required' }
|
|
}
|
|
|
|
const { password, input } = parsedBody
|
|
|
|
if (input && !password) {
|
|
return { authorized: false, error: 'auth_required_password' }
|
|
}
|
|
|
|
if (!password) {
|
|
return { authorized: false, error: 'Password is required' }
|
|
}
|
|
|
|
if (!deployment.password) {
|
|
logger.error(`[${requestId}] No password set for password-protected chat: ${deployment.id}`)
|
|
return { authorized: false, error: 'Authentication configuration error' }
|
|
}
|
|
|
|
const { decrypted } = await decryptSecret(deployment.password)
|
|
if (password !== decrypted) {
|
|
return { authorized: false, error: 'Invalid password' }
|
|
}
|
|
|
|
return { authorized: true }
|
|
} catch (error) {
|
|
logger.error(`[${requestId}] Error validating password:`, error)
|
|
return { authorized: false, error: 'Authentication error' }
|
|
}
|
|
}
|
|
|
|
if (authType === 'email') {
|
|
if (request.method === 'GET') {
|
|
return { authorized: false, error: 'auth_required_email' }
|
|
}
|
|
|
|
try {
|
|
if (!parsedBody) {
|
|
return { authorized: false, error: 'Email is required' }
|
|
}
|
|
|
|
const { email, input } = parsedBody
|
|
|
|
if (input && !email) {
|
|
return { authorized: false, error: 'auth_required_email' }
|
|
}
|
|
|
|
if (!email) {
|
|
return { authorized: false, error: 'Email is required' }
|
|
}
|
|
|
|
const allowedEmails = deployment.allowedEmails || []
|
|
|
|
if (isEmailAllowed(email, allowedEmails)) {
|
|
return { authorized: false, error: 'otp_required' }
|
|
}
|
|
|
|
return { authorized: false, error: 'Email not authorized' }
|
|
} catch (error) {
|
|
logger.error(`[${requestId}] Error validating email:`, error)
|
|
return { authorized: false, error: 'Authentication error' }
|
|
}
|
|
}
|
|
|
|
if (authType === 'sso') {
|
|
if (request.method === 'GET') {
|
|
return { authorized: false, error: 'auth_required_sso' }
|
|
}
|
|
|
|
try {
|
|
if (!parsedBody) {
|
|
return { authorized: false, error: 'SSO authentication is required' }
|
|
}
|
|
|
|
const { email, input, checkSSOAccess } = parsedBody
|
|
|
|
if (input && !checkSSOAccess) {
|
|
return { authorized: false, error: 'auth_required_sso' }
|
|
}
|
|
|
|
if (checkSSOAccess) {
|
|
if (!email) {
|
|
return { authorized: false, error: 'Email is required' }
|
|
}
|
|
|
|
const allowedEmails = deployment.allowedEmails || []
|
|
|
|
if (isEmailAllowed(email, allowedEmails)) {
|
|
return { authorized: true }
|
|
}
|
|
|
|
return { authorized: false, error: 'Email not authorized for SSO access' }
|
|
}
|
|
|
|
const { getSession } = await import('@/lib/auth')
|
|
const session = await getSession()
|
|
|
|
if (!session || !session.user) {
|
|
return { authorized: false, error: 'auth_required_sso' }
|
|
}
|
|
|
|
const userEmail = session.user.email
|
|
if (!userEmail) {
|
|
return { authorized: false, error: 'SSO session does not contain email' }
|
|
}
|
|
|
|
const allowedEmails = deployment.allowedEmails || []
|
|
|
|
if (isEmailAllowed(userEmail, allowedEmails)) {
|
|
return { authorized: true }
|
|
}
|
|
|
|
return { authorized: false, error: 'Your email is not authorized to access this chat' }
|
|
} catch (error) {
|
|
logger.error(`[${requestId}] Error validating SSO:`, error)
|
|
return { authorized: false, error: 'SSO authentication error' }
|
|
}
|
|
}
|
|
|
|
return { authorized: false, error: 'Unsupported authentication type' }
|
|
}
|