Skip to content

Commit f46b1ad

Browse files
Merge branch 'staging' into feat/table-trigger
2 parents a79ebd0 + 66bab93 commit f46b1ad

121 files changed

Lines changed: 4132 additions & 2009 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/test-build.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,15 @@ jobs:
9090
9191
echo "✅ All feature flags are properly configured"
9292
93-
- name: Check subblock ID stability
93+
- name: Check block registry invariants
9494
run: |
9595
if [ "${{ github.event_name }}" = "pull_request" ]; then
9696
BASE_REF="origin/${{ github.base_ref }}"
9797
git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true
9898
else
9999
BASE_REF="HEAD~1"
100100
fi
101-
bun run apps/sim/scripts/check-subblock-id-stability.ts "$BASE_REF"
101+
bun run apps/sim/scripts/check-block-registry.ts "$BASE_REF"
102102
103103
- name: Lint code
104104
run: bun run lint:check

apps/realtime/src/handlers/operations.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '@sim/realtime-protocol/constants'
1111
import { WorkflowOperationSchema } from '@sim/realtime-protocol/schemas'
1212
import { generateId } from '@sim/utils/id'
13+
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
1314
import { ZodError } from 'zod'
1415
import { persistWorkflowOperation } from '@/database/operations'
1516
import type { AuthenticatedSocket } from '@/middleware/auth'
@@ -139,6 +140,24 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager
139140
}
140141
}
141142

143+
try {
144+
await assertWorkflowMutable(workflowId)
145+
} catch (error) {
146+
if (error instanceof WorkflowLockedError) {
147+
emitOperationError(
148+
{
149+
type: 'WORKFLOW_LOCKED',
150+
message: error.message,
151+
operation,
152+
target,
153+
},
154+
{ error: error.message, retryable: false }
155+
)
156+
return
157+
}
158+
throw error
159+
}
160+
142161
// Broadcast first for position updates to minimize latency, then persist
143162
// For other operations, persist first for consistency
144163
if (isPositionUpdate) {

apps/realtime/src/handlers/subblocks.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { workflow, workflowBlocks } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { SUBBLOCK_OPERATIONS } from '@sim/realtime-protocol/constants'
5+
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
56
import { and, eq } from 'drizzle-orm'
67
import type { AuthenticatedSocket } from '@/middleware/auth'
78
import { checkRolePermission } from '@/middleware/permissions'
@@ -151,6 +152,28 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager:
151152
return
152153
}
153154

155+
try {
156+
await assertWorkflowMutable(workflowId)
157+
} catch (error) {
158+
if (error instanceof WorkflowLockedError) {
159+
socket.emit('operation-forbidden', {
160+
type: 'WORKFLOW_LOCKED',
161+
message: error.message,
162+
operation: SUBBLOCK_OPERATIONS.UPDATE,
163+
target: 'subblock',
164+
})
165+
if (operationId) {
166+
socket.emit('operation-failed', {
167+
operationId,
168+
error: error.message,
169+
retryable: false,
170+
})
171+
}
172+
return
173+
}
174+
throw error
175+
}
176+
154177
// Update user activity
155178
await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() })
156179

@@ -231,6 +254,22 @@ async function flushSubblockUpdate(
231254
return
232255
}
233256

257+
try {
258+
await assertWorkflowMutable(workflowId)
259+
} catch (error) {
260+
if (error instanceof WorkflowLockedError) {
261+
pending.opToSocket.forEach((socketId, opId) => {
262+
io.to(socketId).emit('operation-failed', {
263+
operationId: opId,
264+
error: error.message,
265+
retryable: false,
266+
})
267+
})
268+
return
269+
}
270+
throw error
271+
}
272+
234273
let updateSuccessful = false
235274
let blockLocked = false
236275
await db.transaction(async (tx) => {

apps/realtime/src/handlers/variables.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { workflow } from '@sim/db/schema'
33
import { createLogger } from '@sim/logger'
44
import { VARIABLE_OPERATIONS } from '@sim/realtime-protocol/constants'
5+
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
56
import { eq } from 'drizzle-orm'
67
import type { AuthenticatedSocket } from '@/middleware/auth'
78
import { checkRolePermission } from '@/middleware/permissions'
@@ -140,6 +141,28 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager:
140141
return
141142
}
142143

144+
try {
145+
await assertWorkflowMutable(workflowId)
146+
} catch (error) {
147+
if (error instanceof WorkflowLockedError) {
148+
socket.emit('operation-forbidden', {
149+
type: 'WORKFLOW_LOCKED',
150+
message: error.message,
151+
operation: VARIABLE_OPERATIONS.UPDATE,
152+
target: 'variable',
153+
})
154+
if (operationId) {
155+
socket.emit('operation-failed', {
156+
operationId,
157+
error: error.message,
158+
retryable: false,
159+
})
160+
}
161+
return
162+
}
163+
throw error
164+
}
165+
143166
// Update user activity
144167
await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() })
145168

@@ -218,6 +241,22 @@ async function flushVariableUpdate(
218241
return
219242
}
220243

244+
try {
245+
await assertWorkflowMutable(workflowId)
246+
} catch (error) {
247+
if (error instanceof WorkflowLockedError) {
248+
pending.opToSocket.forEach((socketId, opId) => {
249+
io.to(socketId).emit('operation-failed', {
250+
operationId: opId,
251+
error: error.message,
252+
retryable: false,
253+
})
254+
})
255+
return
256+
}
257+
throw error
258+
}
259+
221260
let updateSuccessful = false
222261
await db.transaction(async (tx) => {
223262
const [workflowRecord] = await tx

apps/sim/app/api/chat/[identifier]/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,9 @@ export const POST = withRouteHandler(
149149
request
150150
)
151151

152-
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
152+
if (deployment.authType !== 'sso') {
153+
setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
154+
}
153155

154156
return response
155157
}
@@ -358,6 +360,7 @@ export const GET = withRouteHandler(
358360

359361
if (
360362
deployment.authType !== 'public' &&
363+
deployment.authType !== 'sso' &&
361364
authCookie &&
362365
validateAuthToken(authCookie.value, deployment.id, deployment.password)
363366
) {
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { db } from '@sim/db'
2+
import { chat } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq, isNull } from 'drizzle-orm'
5+
import type { NextRequest } from 'next/server'
6+
import { chatSSOContract } from '@/lib/api/contracts/chats'
7+
import { parseRequest } from '@/lib/api/server'
8+
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
9+
import { RateLimiter } from '@/lib/core/rate-limiter'
10+
import { addCorsHeaders, isEmailAllowed } from '@/lib/core/security/deployment'
11+
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
12+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
13+
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
14+
15+
const logger = createLogger('ChatSSOAPI')
16+
17+
export const dynamic = 'force-dynamic'
18+
export const runtime = 'nodejs'
19+
20+
const rateLimiter = new RateLimiter()
21+
22+
const SSO_IP_RATE_LIMIT: TokenBucketConfig = {
23+
maxTokens: 20,
24+
refillRate: 20,
25+
refillIntervalMs: 15 * 60_000,
26+
}
27+
28+
export const POST = withRouteHandler(
29+
async (request: NextRequest, context: { params: Promise<{ identifier: string }> }) => {
30+
const requestId = generateRequestId()
31+
32+
const ip = getClientIp(request)
33+
if (ip !== 'unknown') {
34+
const ipRateLimit = await rateLimiter.checkRateLimitDirect(
35+
`chat-sso:ip:${ip}`,
36+
SSO_IP_RATE_LIMIT
37+
)
38+
if (!ipRateLimit.allowed) {
39+
logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`)
40+
const retryAfter = Math.ceil(
41+
(ipRateLimit.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs) / 1000
42+
)
43+
const response = createErrorResponse('Too many requests. Please try again later.', 429)
44+
response.headers.set('Retry-After', String(retryAfter))
45+
return addCorsHeaders(response, request)
46+
}
47+
}
48+
49+
const parsed = await parseRequest(chatSSOContract, request, context)
50+
if (!parsed.success) return parsed.response
51+
52+
const { identifier } = parsed.data.params
53+
const { email } = parsed.data.body
54+
55+
const [deployment] = await db
56+
.select({
57+
authType: chat.authType,
58+
allowedEmails: chat.allowedEmails,
59+
isActive: chat.isActive,
60+
})
61+
.from(chat)
62+
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
63+
.limit(1)
64+
65+
if (!deployment || !deployment.isActive) {
66+
logger.warn(`[${requestId}] SSO check on missing/inactive chat: ${identifier}`)
67+
return addCorsHeaders(createErrorResponse('Chat not found', 404), request)
68+
}
69+
70+
if (deployment.authType !== 'sso') {
71+
return addCorsHeaders(
72+
createErrorResponse('Chat is not configured for SSO authentication', 400),
73+
request
74+
)
75+
}
76+
77+
const eligible = isEmailAllowed(email, (deployment.allowedEmails as string[]) || [])
78+
79+
return addCorsHeaders(createSuccessResponse({ eligible }), request)
80+
}
81+
)

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,20 @@ const {
1919
mockSetDeploymentAuthCookie,
2020
mockAddCorsHeaders,
2121
mockIsEmailAllowed,
22+
mockGetSession,
2223
} = vi.hoisted(() => ({
2324
mockMergeSubblockStateWithValues: vi.fn().mockReturnValue({}),
2425
mockMergeSubBlockValues: vi.fn().mockReturnValue({}),
2526
mockValidateAuthToken: vi.fn().mockReturnValue(false),
2627
mockSetDeploymentAuthCookie: vi.fn(),
2728
mockAddCorsHeaders: vi.fn((response: unknown) => response),
2829
mockIsEmailAllowed: vi.fn(),
30+
mockGetSession: vi.fn(),
31+
}))
32+
33+
vi.mock('@/lib/auth', () => ({
34+
auth: { api: { getSession: vi.fn() } },
35+
getSession: mockGetSession,
2936
}))
3037

3138
const mockDecryptSecret = encryptionMockFns.mockDecryptSecret
@@ -285,6 +292,68 @@ describe('Chat API Utils', () => {
285292
expect(result3.authorized).toBe(false)
286293
expect(result3.error).toBe('Email not authorized')
287294
})
295+
296+
describe('SSO auth', () => {
297+
const ssoDeployment = {
298+
id: 'chat-id',
299+
authType: 'sso',
300+
allowedEmails: ['user@example.com', '@company.com'],
301+
}
302+
303+
const postRequest = {
304+
method: 'POST',
305+
cookies: { get: vi.fn().mockReturnValue(null) },
306+
} as any
307+
308+
it('rejects when no session is present', async () => {
309+
mockGetSession.mockResolvedValue(null)
310+
311+
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
312+
input: 'hello',
313+
})
314+
315+
expect(result.authorized).toBe(false)
316+
expect(result.error).toBe('auth_required_sso')
317+
})
318+
319+
it('ignores body-supplied email and uses the session email', async () => {
320+
mockGetSession.mockResolvedValue({ user: { email: 'session@example.com' } })
321+
mockIsEmailAllowed.mockReturnValue(true)
322+
323+
await validateChatAuth('request-id', ssoDeployment, postRequest, {
324+
email: 'attacker@evil.com',
325+
input: 'hello',
326+
})
327+
328+
expect(mockIsEmailAllowed).toHaveBeenCalledWith(
329+
'session@example.com',
330+
ssoDeployment.allowedEmails
331+
)
332+
})
333+
334+
it('authorizes execution when session email is allowlisted', async () => {
335+
mockGetSession.mockResolvedValue({ user: { email: 'user@example.com' } })
336+
mockIsEmailAllowed.mockReturnValue(true)
337+
338+
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
339+
input: 'hello',
340+
})
341+
342+
expect(result.authorized).toBe(true)
343+
})
344+
345+
it('rejects execution when session email is not allowlisted', async () => {
346+
mockGetSession.mockResolvedValue({ user: { email: 'stranger@other.com' } })
347+
mockIsEmailAllowed.mockReturnValue(false)
348+
349+
const result = await validateChatAuth('request-id', ssoDeployment, postRequest, {
350+
input: 'hello',
351+
})
352+
353+
expect(result.authorized).toBe(false)
354+
expect(result.error).toBe('Your email is not authorized to access this chat')
355+
})
356+
})
288357
})
289358

290359
describe('Execution Result Processing', () => {

0 commit comments

Comments
 (0)