Skip to content

Commit 56a88a2

Browse files
v0.7.9: agent file attachments, chat autoscroll, knowledge base upload, security fixes
2 parents 59d9496 + 73c73ff commit 56a88a2

37 files changed

Lines changed: 1154 additions & 90 deletions

File tree

apps/realtime/src/handlers/operations.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
1515
import { ZodError } from 'zod'
1616
import { persistWorkflowOperation } from '@/database/operations'
1717
import type { AuthenticatedSocket } from '@/middleware/auth'
18-
import { checkRolePermission } from '@/middleware/permissions'
18+
import { checkWorkflowOperationPermission } from '@/middleware/permissions'
1919
import type { IRoomManager, UserSession } from '@/rooms'
2020

2121
const logger = createLogger('OperationsHandlers')
@@ -125,11 +125,17 @@ export function setupOperationsHandlers(socket: AuthenticatedSocket, roomManager
125125

126126
await roomManager.updateUserActivity(workflowId, socket.id, { lastActivity: Date.now() })
127127

128-
// Check permissions using cached role (no DB query)
129-
const permissionCheck = checkRolePermission(userPresence.role, operation)
128+
// Re-validate the workspace role against the DB (cached per pod for a short
129+
// window) so revoked or downgraded collaborators lose write access live.
130+
const permissionCheck = await checkWorkflowOperationPermission(
131+
session.userId,
132+
workflowId,
133+
operation,
134+
userPresence.role
135+
)
130136
if (!permissionCheck.allowed) {
131137
logger.warn(
132-
`User ${session.userId} (role: ${userPresence.role}) forbidden from ${operation} on ${target}`
138+
`User ${session.userId} (role: ${permissionCheck.role ?? 'none'}) forbidden from ${operation} on ${target}`
133139
)
134140
emitOperationError({
135141
type: 'INSUFFICIENT_PERMISSIONS',

apps/realtime/src/handlers/subblocks.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
77
import { isWorkflowBlockProtected } from '@sim/workflow-types/workflow'
88
import { and, eq } from 'drizzle-orm'
99
import type { AuthenticatedSocket } from '@/middleware/auth'
10-
import { checkRolePermission } from '@/middleware/permissions'
10+
import { checkWorkflowOperationPermission } from '@/middleware/permissions'
1111
import type { IRoomManager } from '@/rooms'
1212

1313
const logger = createLogger('SubblocksHandlers')
@@ -136,7 +136,12 @@ export function setupSubblocksHandlers(socket: AuthenticatedSocket, roomManager:
136136
return
137137
}
138138

139-
const permissionCheck = checkRolePermission(userPresence.role, SUBBLOCK_OPERATIONS.UPDATE)
139+
const permissionCheck = await checkWorkflowOperationPermission(
140+
session.userId,
141+
workflowId,
142+
SUBBLOCK_OPERATIONS.UPDATE,
143+
userPresence.role
144+
)
140145
if (!permissionCheck.allowed) {
141146
socket.emit('operation-forbidden', {
142147
type: 'INSUFFICIENT_PERMISSIONS',

apps/realtime/src/handlers/variables.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { getErrorMessage } from '@sim/utils/errors'
66
import { assertWorkflowMutable, WorkflowLockedError } from '@sim/workflow-authz'
77
import { eq } from 'drizzle-orm'
88
import type { AuthenticatedSocket } from '@/middleware/auth'
9-
import { checkRolePermission } from '@/middleware/permissions'
9+
import { checkWorkflowOperationPermission } from '@/middleware/permissions'
1010
import type { IRoomManager } from '@/rooms'
1111

1212
const logger = createLogger('VariablesHandlers')
@@ -124,7 +124,12 @@ export function setupVariablesHandlers(socket: AuthenticatedSocket, roomManager:
124124
return
125125
}
126126

127-
const permissionCheck = checkRolePermission(userPresence.role, VARIABLE_OPERATIONS.UPDATE)
127+
const permissionCheck = await checkWorkflowOperationPermission(
128+
session.userId,
129+
workflowId,
130+
VARIABLE_OPERATIONS.UPDATE,
131+
userPresence.role
132+
)
128133
if (!permissionCheck.allowed) {
129134
socket.emit('operation-forbidden', {
130135
type: 'INSUFFICIENT_PERMISSIONS',

apps/realtime/src/index.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ vi.mock('@/middleware/permissions', () => ({
7373
checkRolePermission: vi.fn().mockReturnValue({
7474
allowed: true,
7575
}),
76+
checkWorkflowOperationPermission: vi.fn().mockResolvedValue({
77+
allowed: true,
78+
role: 'admin',
79+
}),
7680
}))
7781

7882
vi.mock('@/database/operations', () => ({

apps/realtime/src/middleware/permissions.test.ts

Lines changed: 137 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,17 @@ import {
1313
ROLE_ALLOWED_OPERATIONS,
1414
SOCKET_OPERATIONS,
1515
} from '@sim/testing'
16-
import { describe, expect, it } from 'vitest'
17-
import { checkRolePermission } from '@/middleware/permissions'
16+
import { beforeEach, describe, expect, it, vi } from 'vitest'
17+
18+
const { mockAuthorize } = vi.hoisted(() => ({
19+
mockAuthorize: vi.fn(),
20+
}))
21+
22+
vi.mock('@sim/workflow-authz', () => ({
23+
authorizeWorkflowByWorkspacePermission: mockAuthorize,
24+
}))
25+
26+
import { checkRolePermission, checkWorkflowOperationPermission } from '@/middleware/permissions'
1827

1928
describe('checkRolePermission', () => {
2029
describe('admin role', () => {
@@ -279,3 +288,129 @@ describe('checkRolePermission', () => {
279288
})
280289
})
281290
})
291+
292+
describe('checkWorkflowOperationPermission', () => {
293+
const userId = 'user-1'
294+
let workflowCounter = 0
295+
let workflowId: string
296+
297+
beforeEach(() => {
298+
vi.clearAllMocks()
299+
// Unique workflowId per test so the module-level role cache never leaks across tests
300+
workflowCounter += 1
301+
workflowId = `wf-${workflowCounter}`
302+
})
303+
304+
it('allows a write operation when the user still has write access', async () => {
305+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
306+
307+
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
308+
309+
expect(result.allowed).toBe(true)
310+
expect(result.role).toBe('write')
311+
})
312+
313+
it('denies all writes once workspace access has been revoked', async () => {
314+
mockAuthorize.mockResolvedValue({ allowed: false, workspacePermission: null })
315+
316+
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
317+
318+
expect(result.allowed).toBe(false)
319+
expect(result.role).toBeNull()
320+
expect(result.reason).toMatch(/revoked/i)
321+
})
322+
323+
it('denies writes after a downgrade to read but still allows position updates', async () => {
324+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'read' })
325+
326+
const denied = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
327+
expect(denied.allowed).toBe(false)
328+
expect(denied.role).toBe('read')
329+
330+
const allowed = await checkWorkflowOperationPermission(
331+
userId,
332+
workflowId,
333+
'update-position',
334+
'write'
335+
)
336+
expect(allowed.allowed).toBe(true)
337+
expect(allowed.role).toBe('read')
338+
})
339+
340+
it('caches the role within the TTL to avoid a DB read on every operation', async () => {
341+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
342+
343+
await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
344+
await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
345+
346+
expect(mockAuthorize).toHaveBeenCalledTimes(1)
347+
})
348+
349+
it('re-reads the role after the cache TTL expires', async () => {
350+
vi.useFakeTimers()
351+
try {
352+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
353+
await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
354+
355+
// Downgraded to read after the first check
356+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'read' })
357+
vi.advanceTimersByTime(31_000)
358+
359+
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
360+
expect(mockAuthorize).toHaveBeenCalledTimes(2)
361+
expect(result.allowed).toBe(false)
362+
expect(result.role).toBe('read')
363+
} finally {
364+
vi.useRealTimers()
365+
}
366+
})
367+
368+
it('falls back to the join-time role on a transient DB error when nothing is cached yet', async () => {
369+
mockAuthorize.mockRejectedValue(new Error('db unavailable'))
370+
371+
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'write')
372+
373+
expect(result.allowed).toBe(true)
374+
expect(result.role).toBe('write')
375+
})
376+
377+
it('preserves a recorded revocation through a later transient DB error', async () => {
378+
vi.useFakeTimers()
379+
try {
380+
// First check records the revocation (null) in the cache
381+
mockAuthorize.mockResolvedValue({ allowed: false, workspacePermission: null })
382+
const first = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'admin')
383+
expect(first.allowed).toBe(false)
384+
expect(first.role).toBeNull()
385+
386+
// TTL expires, then the DB blips on the next re-validation. The stale join-time
387+
// role ('admin') must NOT resurrect access — the recorded revocation wins.
388+
vi.advanceTimersByTime(31_000)
389+
mockAuthorize.mockRejectedValue(new Error('db unavailable'))
390+
391+
const second = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'admin')
392+
expect(second.allowed).toBe(false)
393+
expect(second.role).toBeNull()
394+
} finally {
395+
vi.useRealTimers()
396+
}
397+
})
398+
399+
it('uses the last cached role (not the join-time role) on a transient DB error', async () => {
400+
vi.useFakeTimers()
401+
try {
402+
mockAuthorize.mockResolvedValue({ allowed: true, workspacePermission: 'write' })
403+
await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
404+
405+
vi.advanceTimersByTime(31_000)
406+
mockAuthorize.mockRejectedValue(new Error('db unavailable'))
407+
408+
// fallbackRole is 'read', but the last recorded decision was 'write' — use that
409+
const result = await checkWorkflowOperationPermission(userId, workflowId, 'update', 'read')
410+
expect(result.allowed).toBe(true)
411+
expect(result.role).toBe('write')
412+
} finally {
413+
vi.useRealTimers()
414+
}
415+
})
416+
})

apps/realtime/src/middleware/permissions.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,111 @@ export function checkRolePermission(
8383
return { allowed: true }
8484
}
8585

86+
/**
87+
* TTL for the per-pod role cache backing live re-validation of mutating operations.
88+
* Bounds how long a revoked or downgraded collaborator can retain write access on an
89+
* already-connected socket.
90+
*/
91+
const ROLE_REVALIDATION_TTL_MS = 30_000
92+
93+
/** Soft cap on cached entries before an opportunistic purge of expired ones runs. */
94+
const MAX_ROLE_CACHE_ENTRIES = 5_000
95+
96+
interface CachedRole {
97+
/** Authoritative workspace role, or `null` when the user has no access. */
98+
role: string | null
99+
expiresAt: number
100+
}
101+
102+
/**
103+
* Per-pod cache of authoritative workspace roles, keyed by `${userId}:${workflowId}`.
104+
*
105+
* Socket connections are sticky to a single pod, so a socket's mutating operations are
106+
* always gated by the same pod's cache. We rely on TTL expiry (not cross-pod
107+
* invalidation) to bound stale authorization to {@link ROLE_REVALIDATION_TTL_MS}, which
108+
* keeps this correct under a multi-pod deployment without any shared state.
109+
*/
110+
const roleCache = new Map<string, CachedRole>()
111+
112+
function purgeExpiredRoles(now: number): void {
113+
for (const [key, entry] of roleCache) {
114+
if (entry.expiresAt <= now) {
115+
roleCache.delete(key)
116+
}
117+
}
118+
}
119+
120+
/**
121+
* Resolves a user's current workspace role for a workflow, re-reading the `permissions`
122+
* table at most once per {@link ROLE_REVALIDATION_TTL_MS} per pod.
123+
*
124+
* Returns `null` when the user genuinely has no access (removed/revoked). On a transient
125+
* DB failure it reuses the last recorded decision for this (user, workflow) — including a
126+
* previously recorded revocation (`null`) — and only falls back to `fallbackRole` when no
127+
* decision has been recorded yet, so a blip neither blocks legitimate editors nor
128+
* resurrects already-revoked access.
129+
*/
130+
export async function resolveCurrentWorkflowRole(
131+
userId: string,
132+
workflowId: string,
133+
fallbackRole: string
134+
): Promise<string | null> {
135+
const now = Date.now()
136+
const key = `${userId}:${workflowId}`
137+
const cached = roleCache.get(key)
138+
if (cached && cached.expiresAt > now) {
139+
return cached.role
140+
}
141+
142+
try {
143+
const authorization = await authorizeWorkflowByWorkspacePermission({
144+
workflowId,
145+
userId,
146+
action: 'read',
147+
})
148+
const role = authorization.allowed ? (authorization.workspacePermission ?? null) : null
149+
if (roleCache.size >= MAX_ROLE_CACHE_ENTRIES) {
150+
purgeExpiredRoles(now)
151+
}
152+
roleCache.set(key, { role, expiresAt: now + ROLE_REVALIDATION_TTL_MS })
153+
return role
154+
} catch (error) {
155+
logger.warn(
156+
`Failed to re-validate role for user ${userId} on workflow ${workflowId}; using last known role`,
157+
error
158+
)
159+
// Prefer the last recorded decision — even if expired, and even if it is `null` for an
160+
// already-revoked user — so a recorded revocation survives a transient DB failure
161+
// instead of reverting to the stale join-time role. Only trust `fallbackRole` when
162+
// nothing has been recorded for this (user, workflow) yet.
163+
const lastKnown = roleCache.get(key)
164+
return lastKnown !== undefined ? lastKnown.role : fallbackRole
165+
}
166+
}
167+
168+
/**
169+
* Live permission gate for mutating socket operations. Re-validates the user's workspace
170+
* role against the database (cached per pod for {@link ROLE_REVALIDATION_TTL_MS}) so that
171+
* revoked or downgraded collaborators lose write access on an open connection without
172+
* needing to rejoin the workflow.
173+
*/
174+
export async function checkWorkflowOperationPermission(
175+
userId: string,
176+
workflowId: string,
177+
operation: string,
178+
fallbackRole: string
179+
): Promise<{ allowed: boolean; reason?: string; role: string | null }> {
180+
const role = await resolveCurrentWorkflowRole(userId, workflowId, fallbackRole)
181+
if (!role) {
182+
return {
183+
allowed: false,
184+
reason: 'Access to this workflow has been revoked',
185+
role: null,
186+
}
187+
}
188+
return { ...checkRolePermission(role, operation), role }
189+
}
190+
86191
/**
87192
* Verifies a user's access to a workflow via workspace permissions.
88193
*

apps/sim/app/(landing)/models/(shell)/[provider]/page.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { ModelTimelineChart } from '@/app/(landing)/models/components/model-timeline-chart'
1414
import {
1515
buildProviderFaqs,
16+
formatFileSize,
1617
formatPrice,
1718
formatTokenCount,
1819
getProviderBySlug,
@@ -204,9 +205,16 @@ export default async function ProviderModelsPage({
204205
{provider.name} models
205206
</h1>
206207
</div>
207-
<span className='shrink-0 font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
208-
{provider.modelCount} models
209-
</span>
208+
<div className='flex shrink-0 flex-col items-end gap-1'>
209+
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
210+
{provider.modelCount} models
211+
</span>
212+
{provider.maxFileAttachmentBytes ? (
213+
<span className='font-martian-mono text-[var(--landing-text-subtle)] text-xs uppercase tracking-[0.1em]'>
214+
{formatFileSize(provider.maxFileAttachmentBytes)} file uploads
215+
</span>
216+
) : null}
217+
</div>
210218
</div>
211219
</div>
212220

0 commit comments

Comments
 (0)