Skip to content

Commit e598c51

Browse files
committed
guardrail attribution
1 parent 7fea1a4 commit e598c51

5 files changed

Lines changed: 96 additions & 24 deletions

File tree

apps/sim/app/api/files/multipart/route.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,24 @@ describe('POST /api/files/multipart action=initiate quota enforcement', () => {
275275
expect(mockInitiateS3MultipartUpload).toHaveBeenCalled()
276276
})
277277

278+
it('keeps mothership chat uploads outside workspace storage quotas', async () => {
279+
mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' })
280+
281+
const res = await makeInitiateRequest({
282+
fileName: 'conversation.bin',
283+
contentType: 'application/octet-stream',
284+
fileSize: 99999,
285+
workspaceId: 'ws-1',
286+
context: 'mothership',
287+
})
288+
289+
const response = await POST(res)
290+
expect(response.status).toBe(200)
291+
expect(mockResolveStorageBillingContext).not.toHaveBeenCalled()
292+
expect(mockCheckStorageQuota).not.toHaveBeenCalled()
293+
expect(mockInitiateS3MultipartUpload).toHaveBeenCalled()
294+
})
295+
278296
it.each(['og-images', 'profile-pictures', 'workspace-logos', 'logs'])(
279297
'rejects quota-exempt context %s — not allowed via the multipart endpoint',
280298
async (context) => {

apps/sim/app/api/files/multipart/route.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,11 @@ import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
3131
const logger = createLogger('MultipartUploadAPI')
3232

3333
/**
34-
* Contexts the multipart endpoint accepts. The quota-exempt public-asset
35-
* contexts (`profile-pictures`, `workspace-logos`, `og-images`) and the
36-
* system-internal `logs` context are deliberately excluded: their uploads are
37-
* small images capped far below the multipart threshold and routed through the
38-
* presigned endpoint, so they have no large-file flow here. Accepting them would
39-
* only expose a path that bypasses the per-user storage quota, since every
40-
* context in this set is quota-enforced below.
34+
* Contexts the multipart endpoint accepts. Small public assets and internal logs
35+
* are excluded because they have no large-file flow. Mothership remains
36+
* available for large chat attachments but is quota-exempt because chat uploads
37+
* do not count as durable workspace-file storage. Every other accepted context
38+
* is quota-enforced below.
4139
*/
4240
const ALLOWED_UPLOAD_CONTEXTS = new Set<StorageContext>([
4341
'knowledge-base',

apps/sim/app/api/guardrails/validate/route.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,4 +266,47 @@ describe('POST /api/guardrails/validate', () => {
266266
})
267267
)
268268
})
269+
270+
it('rejects invalid internal billing attribution as a protocol error', async () => {
271+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
272+
success: true,
273+
userId: 'user-1',
274+
authType: 'internal_jwt',
275+
})
276+
mockRequireBillingAttributionHeader.mockImplementationOnce(() => {
277+
throw new Error('Billing attribution header is required')
278+
})
279+
280+
const res = await POST(
281+
createMockRequest('POST', {
282+
validationType: 'hallucination',
283+
input: 'test input',
284+
knowledgeBaseId: 'kb-1',
285+
model: 'gpt-4o',
286+
workflowId: 'wf-1',
287+
})
288+
)
289+
290+
expect(res.status).toBe(400)
291+
await expect(res.json()).resolves.toEqual({ error: 'Invalid billing attribution' })
292+
expect(mockValidateHallucination).not.toHaveBeenCalled()
293+
})
294+
295+
it('surfaces workspace billing resolution failures as infrastructure errors', async () => {
296+
mockResolveBillingAttribution.mockRejectedValueOnce(new Error('Database unavailable'))
297+
298+
const res = await POST(
299+
createMockRequest('POST', {
300+
validationType: 'hallucination',
301+
input: 'test input',
302+
knowledgeBaseId: 'kb-1',
303+
model: 'gpt-4o',
304+
workflowId: 'wf-1',
305+
})
306+
)
307+
308+
expect(res.status).toBe(500)
309+
await expect(res.json()).resolves.toEqual({ error: 'Failed to resolve billing attribution' })
310+
expect(mockValidateHallucination).not.toHaveBeenCalled()
311+
})
269312
})

apps/sim/app/api/guardrails/validate/route.ts

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -166,16 +166,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
166166
}
167167

168168
resolvedWorkspaceId = authorization.workflow.workspaceId
169-
billingAttribution =
170-
auth.authType === AuthType.INTERNAL_JWT
171-
? requireBillingAttributionHeader(request.headers, {
172-
actorUserId: auth.userId,
173-
workspaceId: resolvedWorkspaceId,
174-
})
175-
: await resolveBillingAttribution({
176-
actorUserId: auth.userId,
177-
workspaceId: resolvedWorkspaceId,
178-
})
169+
try {
170+
billingAttribution =
171+
auth.authType === AuthType.INTERNAL_JWT
172+
? requireBillingAttributionHeader(request.headers, {
173+
actorUserId: auth.userId,
174+
workspaceId: resolvedWorkspaceId,
175+
})
176+
: await resolveBillingAttribution({
177+
actorUserId: auth.userId,
178+
workspaceId: resolvedWorkspaceId,
179+
})
180+
} catch (error) {
181+
const isInternalRequest = auth.authType === AuthType.INTERNAL_JWT
182+
logger.error(`[${requestId}] Failed to establish billing attribution`, { error })
183+
return NextResponse.json(
184+
{
185+
error: isInternalRequest
186+
? 'Invalid billing attribution'
187+
: 'Failed to resolve billing attribution',
188+
},
189+
{ status: isInternalRequest ? 400 : 500 }
190+
)
191+
}
179192

180193
try {
181194
await assertPermissionsAllowed({

apps/sim/lib/uploads/shared/types.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,16 @@ export type StorageContext =
2626
/**
2727
* Contexts exempt from storage quota checks. Includes system-internal contexts
2828
* (`logs` — written by the execution pipeline, not user-initiated) and small
29-
* metadata assets (`profile-pictures`, `workspace-logos`, `og-images`). All
30-
* other contexts are user-driven uploads and must pass quota validation.
29+
* metadata assets (`profile-pictures`, `workspace-logos`, `og-images`).
30+
* Mothership chat attachments are also exempt because they are not counted as
31+
* durable workspace-file storage.
3132
*
32-
* Note: every quota-exempt context is excluded from `ALLOWED_UPLOAD_CONTEXTS`
33-
* in the multipart endpoint, so none are reachable there — the exemption applies
34-
* only to the single-part upload paths (presigned/FormData) those small assets
35-
* actually use. The multipart endpoint therefore only ever serves
36-
* quota-enforced contexts.
33+
* The small-asset and system contexts are excluded from the multipart endpoint.
34+
* Mothership remains available there for large chat attachments while retaining
35+
* the same quota exemption as its single-part upload path.
3736
*/
3837
export const QUOTA_EXEMPT_STORAGE_CONTEXTS = new Set<StorageContext>([
38+
'mothership',
3939
'profile-pictures',
4040
'workspace-logos',
4141
'og-images',

0 commit comments

Comments
 (0)