Skip to content

Commit 9ea7ea7

Browse files
feat(workspace-vars): add workspace scoped environment + fix cancellation of assoc. workspace invites if org invite cancelled (#1208)
* feat(env-vars): workspace scoped environment variables * fix cascade delete or workspace invite if org invite with attached workspace invites are created * remove redundant refetch * feat(env-vars): workspace scoped environment variables * fix redirect for invitation error, remove check for validated emails on workspace invitation accept * styling improvements * remove random migration code * stronger typing, added helpers, parallelized envvar encryption --------- Co-authored-by: waleedlatif1 <walif6@gmail.com>
1 parent 5bbb349 commit 9ea7ea7

File tree

36 files changed

+12893
-361
lines changed

36 files changed

+12893
-361
lines changed

apps/sim/app/api/chat/utils.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { v4 as uuidv4 } from 'uuid'
44
import { checkServerSideUsageLimits } from '@/lib/billing'
55
import { isDev } from '@/lib/environment'
6+
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
67
import { createLogger } from '@/lib/logs/console/logger'
78
import { LoggingSession } from '@/lib/logs/execution/logging-session'
89
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
@@ -12,7 +13,7 @@ import { getEmailDomain } from '@/lib/urls/utils'
1213
import { decryptSecret } from '@/lib/utils'
1314
import { getBlock } from '@/blocks'
1415
import { db } from '@/db'
15-
import { chat, environment as envTable, userStats, workflow } from '@/db/schema'
16+
import { chat, userStats, workflow } from '@/db/schema'
1617
import { Executor } from '@/executor'
1718
import type { BlockLog, ExecutionResult } from '@/executor/types'
1819
import { Serializer } from '@/serializer'
@@ -453,18 +454,21 @@ export async function executeWorkflowForChat(
453454
{} as Record<string, Record<string, any>>
454455
)
455456

456-
// Get user environment variables for this workflow
457+
// Get user environment variables with workspace precedence
457458
let envVars: Record<string, string> = {}
458459
try {
459-
const envResult = await db
460-
.select()
461-
.from(envTable)
462-
.where(eq(envTable.userId, deployment.userId))
460+
const wfWorkspaceRow = await db
461+
.select({ workspaceId: workflow.workspaceId })
462+
.from(workflow)
463+
.where(eq(workflow.id, workflowId))
463464
.limit(1)
464465

465-
if (envResult.length > 0 && envResult[0].variables) {
466-
envVars = envResult[0].variables as Record<string, string>
467-
}
466+
const workspaceId = wfWorkspaceRow[0]?.workspaceId || undefined
467+
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
468+
deployment.userId,
469+
workspaceId
470+
)
471+
envVars = { ...personalEncrypted, ...workspaceEncrypted }
468472
} catch (error) {
469473
logger.warn(`[${requestId}] Could not fetch environment variables:`, error)
470474
}

apps/sim/app/api/environment/route.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,12 @@ export async function POST(req: NextRequest) {
3131
const { variables } = EnvVarSchema.parse(body)
3232

3333
// Encrypt all variables
34-
const encryptedVariables = await Object.entries(variables).reduce(
35-
async (accPromise, [key, value]) => {
36-
const acc = await accPromise
34+
const encryptedVariables = await Promise.all(
35+
Object.entries(variables).map(async ([key, value]) => {
3736
const { encrypted } = await encryptSecret(value)
38-
return { ...acc, [key]: encrypted }
39-
},
40-
Promise.resolve({})
41-
)
37+
return [key, encrypted] as const
38+
})
39+
).then((entries) => Object.fromEntries(entries))
4240

4341
// Replace all environment variables for user
4442
await db

apps/sim/app/api/environment/variables/route.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -120,14 +120,12 @@ export async function PUT(request: NextRequest) {
120120
}
121121

122122
// Only encrypt the variables that are new or changed
123-
const newlyEncryptedVariables = await Object.entries(variablesToEncrypt).reduce(
124-
async (accPromise, [key, value]) => {
125-
const acc = await accPromise
123+
const newlyEncryptedVariables = await Promise.all(
124+
Object.entries(variablesToEncrypt).map(async ([key, value]) => {
126125
const { encrypted } = await encryptSecret(value)
127-
return { ...acc, [key]: encrypted }
128-
},
129-
Promise.resolve({})
130-
)
126+
return [key, encrypted] as const
127+
})
128+
).then((entries) => Object.fromEntries(entries))
131129

132130
// Merge existing encrypted variables with newly encrypted ones
133131
const finalEncryptedVariables = { ...existingEncryptedVariables, ...newlyEncryptedVariables }

apps/sim/app/api/organizations/[id]/invitations/route.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { randomUUID } from 'crypto'
2-
import { and, eq, inArray } from 'drizzle-orm'
2+
import { and, eq, inArray, isNull } from 'drizzle-orm'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import {
55
getEmailSubject,
@@ -284,6 +284,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
284284
const workspaceInvitationIds: string[] = []
285285
if (isBatch && validWorkspaceInvitations.length > 0) {
286286
for (const email of emailsToInvite) {
287+
const orgInviteForEmail = invitationsToCreate.find((inv) => inv.email === email)
287288
for (const wsInvitation of validWorkspaceInvitations) {
288289
const wsInvitationId = randomUUID()
289290
const token = randomUUID()
@@ -297,6 +298,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
297298
status: 'pending',
298299
token,
299300
permissions: wsInvitation.permission,
301+
orgInvitationId: orgInviteForEmail?.id,
300302
expiresAt,
301303
createdAt: new Date(),
302304
updatedAt: new Date(),
@@ -467,9 +469,7 @@ export async function DELETE(
467469
// Cancel the invitation
468470
const result = await db
469471
.update(invitation)
470-
.set({
471-
status: 'cancelled',
472-
})
472+
.set({ status: 'cancelled' })
473473
.where(
474474
and(
475475
eq(invitation.id, invitationId),
@@ -486,6 +486,26 @@ export async function DELETE(
486486
)
487487
}
488488

489+
// Also cancel any linked workspace invitations created as part of the batch
490+
await db
491+
.update(workspaceInvitation)
492+
.set({ status: 'cancelled' })
493+
.where(eq(workspaceInvitation.orgInvitationId, invitationId))
494+
495+
// Legacy fallback: cancel any pending workspace invitations for the same email
496+
// that do not have an orgInvitationId and were created by the same inviter
497+
await db
498+
.update(workspaceInvitation)
499+
.set({ status: 'cancelled' })
500+
.where(
501+
and(
502+
isNull(workspaceInvitation.orgInvitationId),
503+
eq(workspaceInvitation.email, result[0].email),
504+
eq(workspaceInvitation.status, 'pending'),
505+
eq(workspaceInvitation.inviterId, session.user.id)
506+
)
507+
)
508+
489509
logger.info('Organization invitation cancelled', {
490510
organizationId,
491511
invitationId,

apps/sim/app/api/organizations/invitations/accept/route.ts

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { randomUUID } from 'crypto'
2-
import { and, eq } from 'drizzle-orm'
2+
import { and, eq, isNull, or } from 'drizzle-orm'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { getSession } from '@/lib/auth'
55
import { env } from '@/lib/env'
@@ -82,16 +82,6 @@ export async function GET(req: NextRequest) {
8282
)
8383
}
8484

85-
// Check if user's email is verified
86-
if (!userData[0].emailVerified) {
87-
return NextResponse.redirect(
88-
new URL(
89-
`/invite/invite-error?reason=email-not-verified&details=${encodeURIComponent(`You must verify your email address (${userData[0].email}) before accepting invitations.`)}`,
90-
env.NEXT_PUBLIC_APP_URL || 'https://sim.ai'
91-
)
92-
)
93-
}
94-
9585
// Verify the email matches the current user
9686
if (orgInvitation.email !== session.user.email) {
9787
return NextResponse.redirect(
@@ -137,14 +127,21 @@ export async function GET(req: NextRequest) {
137127
// Mark organization invitation as accepted
138128
await tx.update(invitation).set({ status: 'accepted' }).where(eq(invitation.id, invitationId))
139129

140-
// Find and accept any pending workspace invitations for the same email
130+
// Find and accept any pending workspace invitations linked to this org invite.
131+
// For backward compatibility, also include legacy pending invites by email with no org link.
141132
const workspaceInvitations = await tx
142133
.select()
143134
.from(workspaceInvitation)
144135
.where(
145136
and(
146-
eq(workspaceInvitation.email, orgInvitation.email),
147-
eq(workspaceInvitation.status, 'pending')
137+
eq(workspaceInvitation.status, 'pending'),
138+
or(
139+
eq(workspaceInvitation.orgInvitationId, invitationId),
140+
and(
141+
isNull(workspaceInvitation.orgInvitationId),
142+
eq(workspaceInvitation.email, orgInvitation.email)
143+
)
144+
)
148145
)
149146
)
150147

@@ -264,17 +261,6 @@ export async function POST(req: NextRequest) {
264261
return NextResponse.json({ error: 'User not found' }, { status: 404 })
265262
}
266263

267-
// Check if user's email is verified
268-
if (!userData[0].emailVerified) {
269-
return NextResponse.json(
270-
{
271-
error: 'Email not verified',
272-
message: `You must verify your email address (${userData[0].email}) before accepting invitations.`,
273-
},
274-
{ status: 403 }
275-
)
276-
}
277-
278264
if (orgInvitation.email !== session.user.email) {
279265
return NextResponse.json({ error: 'Email mismatch' }, { status: 403 })
280266
}

apps/sim/app/api/schedules/execute/route.ts

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { NextResponse } from 'next/server'
44
import { v4 as uuidv4 } from 'uuid'
55
import { z } from 'zod'
66
import { checkServerSideUsageLimits } from '@/lib/billing'
7+
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
78
import { createLogger } from '@/lib/logs/console/logger'
89
import { LoggingSession } from '@/lib/logs/execution/logging-session'
910
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
@@ -17,13 +18,7 @@ import { decryptSecret } from '@/lib/utils'
1718
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/db-helpers'
1819
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
1920
import { db } from '@/db'
20-
import {
21-
environment as environmentTable,
22-
subscription,
23-
userStats,
24-
workflow,
25-
workflowSchedule,
26-
} from '@/db/schema'
21+
import { subscription, userStats, workflow, workflowSchedule } from '@/db/schema'
2722
import { Executor } from '@/executor'
2823
import { Serializer } from '@/serializer'
2924
import { RateLimiter } from '@/services/queue'
@@ -236,20 +231,15 @@ export async function GET() {
236231

237232
const mergedStates = mergeSubblockState(blocks)
238233

239-
// Retrieve environment variables for this user (if any).
240-
const [userEnv] = await db
241-
.select()
242-
.from(environmentTable)
243-
.where(eq(environmentTable.userId, workflowRecord.userId))
244-
.limit(1)
245-
246-
if (!userEnv) {
247-
logger.debug(
248-
`[${requestId}] No environment record found for user ${workflowRecord.userId}. Proceeding with empty variables.`
249-
)
250-
}
251-
252-
const variables = EnvVarsSchema.parse(userEnv?.variables ?? {})
234+
// Retrieve environment variables with workspace precedence
235+
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
236+
workflowRecord.userId,
237+
workflowRecord.workspaceId || undefined
238+
)
239+
const variables = EnvVarsSchema.parse({
240+
...personalEncrypted,
241+
...workspaceEncrypted,
242+
})
253243

254244
const currentBlockStates = await Object.entries(mergedStates).reduce(
255245
async (accPromise, [id, block]) => {

apps/sim/app/api/workflows/[id]/execute/route.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { v4 as uuidv4 } from 'uuid'
55
import { z } from 'zod'
66
import { getSession } from '@/lib/auth'
77
import { checkServerSideUsageLimits } from '@/lib/billing'
8+
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
89
import { createLogger } from '@/lib/logs/console/logger'
910
import { LoggingSession } from '@/lib/logs/execution/logging-session'
1011
import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans'
@@ -18,7 +19,7 @@ import {
1819
import { validateWorkflowAccess } from '@/app/api/workflows/middleware'
1920
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
2021
import { db } from '@/db'
21-
import { environment as environmentTable, subscription, userStats } from '@/db/schema'
22+
import { subscription, userStats } from '@/db/schema'
2223
import { Executor } from '@/executor'
2324
import { Serializer } from '@/serializer'
2425
import {
@@ -64,7 +65,12 @@ class UsageLimitError extends Error {
6465
}
6566
}
6667

67-
async function executeWorkflow(workflow: any, requestId: string, input?: any): Promise<any> {
68+
async function executeWorkflow(
69+
workflow: any,
70+
requestId: string,
71+
input?: any,
72+
executingUserId?: string
73+
): Promise<any> {
6874
const workflowId = workflow.id
6975
const executionId = uuidv4()
7076

@@ -127,23 +133,15 @@ async function executeWorkflow(workflow: any, requestId: string, input?: any): P
127133
// Use the same execution flow as in scheduled executions
128134
const mergedStates = mergeSubblockState(blocks)
129135

130-
// Fetch the user's environment variables (if any)
131-
const [userEnv] = await db
132-
.select()
133-
.from(environmentTable)
134-
.where(eq(environmentTable.userId, workflow.userId))
135-
.limit(1)
136-
137-
if (!userEnv) {
138-
logger.debug(
139-
`[${requestId}] No environment record found for user ${workflow.userId}. Proceeding with empty variables.`
140-
)
141-
}
142-
143-
const variables = EnvVarsSchema.parse(userEnv?.variables ?? {})
136+
// Load personal (for the executing user) and workspace env (workspace overrides personal)
137+
const { personalEncrypted, workspaceEncrypted } = await getPersonalAndWorkspaceEnv(
138+
executingUserId || workflow.userId,
139+
workflow.workspaceId || undefined
140+
)
141+
const variables = EnvVarsSchema.parse({ ...personalEncrypted, ...workspaceEncrypted })
144142

145143
await loggingSession.safeStart({
146-
userId: workflow.userId,
144+
userId: executingUserId || workflow.userId,
147145
workspaceId: workflow.workspaceId,
148146
variables,
149147
})
@@ -400,7 +398,13 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{
400398
}
401399
}
402400

403-
const result = await executeWorkflow(validation.workflow, requestId, undefined)
401+
const result = await executeWorkflow(
402+
validation.workflow,
403+
requestId,
404+
undefined,
405+
// Executing user (manual run): if session present, use that user for fallback
406+
(await getSession())?.user?.id || undefined
407+
)
404408

405409
// Check if the workflow execution contains a response block output
406410
const hasResponseBlock = workflowHasResponseBlock(result)
@@ -589,7 +593,12 @@ export async function POST(
589593
)
590594
}
591595

592-
const result = await executeWorkflow(validation.workflow, requestId, input)
596+
const result = await executeWorkflow(
597+
validation.workflow,
598+
requestId,
599+
input,
600+
authenticatedUserId
601+
)
593602

594603
const hasResponseBlock = workflowHasResponseBlock(result)
595604
if (hasResponseBlock) {

0 commit comments

Comments
 (0)