Skip to content

Commit ff8844c

Browse files
authored
fix(guardrails): authorize vertexCredential before use in hallucination validation (#5400)
* fix(guardrails): authorize vertexCredential before use in hallucination validation The guardrails validate route now checks credential access via authorizeCredentialUse before passing a caller-supplied vertexCredential into hallucination validation, matching the existing pattern already used in the providers route for the same field. * fix(guardrails): gate vertexCredential authorization on the resolved provider Only run the credential check when the model actually resolves to vertex, matching the providers route's gating exactly, and drop a redundant fallback now that workflowId is already guaranteed present at that point.
1 parent 6bc70cb commit ff8844c

2 files changed

Lines changed: 179 additions & 0 deletions

File tree

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest, hybridAuthMockFns, workflowAuthzMockFns } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockAuthorizeCredentialUse, mockCheckActorUsageLimits, mockValidateHallucination } =
8+
vi.hoisted(() => ({
9+
mockAuthorizeCredentialUse: vi.fn(),
10+
mockCheckActorUsageLimits: vi.fn(),
11+
mockValidateHallucination: vi.fn(),
12+
}))
13+
14+
vi.mock('@/lib/auth/credential-access', () => ({
15+
authorizeCredentialUse: mockAuthorizeCredentialUse,
16+
}))
17+
18+
vi.mock('@/lib/billing/calculations/usage-monitor', () => ({
19+
checkActorUsageLimits: mockCheckActorUsageLimits,
20+
}))
21+
22+
vi.mock('@/lib/guardrails/validate_hallucination', () => ({
23+
validateHallucination: mockValidateHallucination,
24+
}))
25+
26+
vi.mock('@/lib/guardrails/validate_json', () => ({
27+
validateJson: vi.fn(() => ({ passed: true })),
28+
}))
29+
30+
vi.mock('@/lib/guardrails/validate_pii', () => ({
31+
validatePII: vi.fn(() => ({ passed: true })),
32+
}))
33+
34+
vi.mock('@/lib/guardrails/validate_regex', () => ({
35+
validateRegex: vi.fn(() => ({ passed: true })),
36+
}))
37+
38+
vi.mock('@/ee/access-control/utils/permission-check', () => ({
39+
assertPermissionsAllowed: vi.fn(),
40+
ModelNotAllowedError: class ModelNotAllowedError extends Error {},
41+
ProviderNotAllowedError: class ProviderNotAllowedError extends Error {},
42+
}))
43+
44+
import { POST } from '@/app/api/guardrails/validate/route'
45+
46+
describe('POST /api/guardrails/validate', () => {
47+
beforeEach(() => {
48+
vi.clearAllMocks()
49+
hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockResolvedValue({
50+
success: true,
51+
userId: 'user-1',
52+
authType: 'session',
53+
})
54+
workflowAuthzMockFns.mockAuthorizeWorkflowByWorkspacePermission.mockResolvedValue({
55+
allowed: true,
56+
workflow: { id: 'wf-1', workspaceId: 'ws-1' },
57+
})
58+
mockCheckActorUsageLimits.mockResolvedValue({ isExceeded: false })
59+
mockValidateHallucination.mockResolvedValue({ passed: true, score: 8 })
60+
})
61+
62+
it('rejects a vertexCredential the caller does not have access to before calling validateHallucination', async () => {
63+
mockAuthorizeCredentialUse.mockResolvedValue({
64+
ok: false,
65+
error: 'You do not have access to this credential.',
66+
})
67+
68+
const res = await POST(
69+
createMockRequest('POST', {
70+
validationType: 'hallucination',
71+
input: 'test input',
72+
knowledgeBaseId: 'kb-1',
73+
model: 'vertex/gemini-2.5-pro',
74+
workflowId: 'wf-1',
75+
vertexCredential: 'someone-elses-account-id',
76+
})
77+
)
78+
79+
expect(res.status).toBe(401)
80+
expect(mockAuthorizeCredentialUse).toHaveBeenCalledWith(
81+
expect.anything(),
82+
expect.objectContaining({
83+
credentialId: 'someone-elses-account-id',
84+
workflowId: 'wf-1',
85+
requireWorkflowIdForInternal: false,
86+
})
87+
)
88+
expect(mockValidateHallucination).not.toHaveBeenCalled()
89+
})
90+
91+
it('proceeds with hallucination validation when the caller has access to the vertexCredential', async () => {
92+
mockAuthorizeCredentialUse.mockResolvedValue({ ok: true })
93+
94+
const res = await POST(
95+
createMockRequest('POST', {
96+
validationType: 'hallucination',
97+
input: 'test input',
98+
knowledgeBaseId: 'kb-1',
99+
model: 'vertex/gemini-2.5-pro',
100+
workflowId: 'wf-1',
101+
vertexCredential: 'my-own-account-id',
102+
})
103+
)
104+
105+
expect(res.status).toBe(200)
106+
const json = await res.json()
107+
expect(json.output.passed).toBe(true)
108+
expect(mockAuthorizeCredentialUse).toHaveBeenCalledWith(
109+
expect.anything(),
110+
expect.objectContaining({ credentialId: 'my-own-account-id' })
111+
)
112+
expect(mockValidateHallucination).toHaveBeenCalled()
113+
})
114+
115+
it('does not gate on vertexCredential for non-hallucination validation types', async () => {
116+
const res = await POST(
117+
createMockRequest('POST', {
118+
validationType: 'json',
119+
input: '{"a":1}',
120+
})
121+
)
122+
123+
expect(res.status).toBe(200)
124+
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
125+
})
126+
127+
it('does not gate hallucination validation when no vertexCredential is supplied', async () => {
128+
const res = await POST(
129+
createMockRequest('POST', {
130+
validationType: 'hallucination',
131+
input: 'test input',
132+
knowledgeBaseId: 'kb-1',
133+
model: 'gpt-4o',
134+
workflowId: 'wf-1',
135+
})
136+
)
137+
138+
expect(res.status).toBe(200)
139+
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
140+
expect(mockValidateHallucination).toHaveBeenCalled()
141+
})
142+
143+
it('does not gate on a leftover vertexCredential when the resolved model is not vertex', async () => {
144+
const res = await POST(
145+
createMockRequest('POST', {
146+
validationType: 'hallucination',
147+
input: 'test input',
148+
knowledgeBaseId: 'kb-1',
149+
model: 'gpt-4o',
150+
workflowId: 'wf-1',
151+
vertexCredential: 'someone-elses-account-id',
152+
})
153+
)
154+
155+
expect(res.status).toBe(200)
156+
expect(mockAuthorizeCredentialUse).not.toHaveBeenCalled()
157+
expect(mockValidateHallucination).toHaveBeenCalled()
158+
})
159+
})

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/work
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { guardrailsValidateContract } from '@/lib/api/contracts'
55
import { parseRequest } from '@/lib/api/server'
6+
import { authorizeCredentialUse } from '@/lib/auth/credential-access'
67
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
78
import { checkActorUsageLimits } from '@/lib/billing/calculations/usage-monitor'
89
import { generateRequestId } from '@/lib/core/utils/request'
@@ -16,6 +17,7 @@ import {
1617
ModelNotAllowedError,
1718
ProviderNotAllowedError,
1819
} from '@/ee/access-control/utils/permission-check'
20+
import { getProviderFromModel } from '@/providers/utils'
1921

2022
const logger = createLogger('GuardrailsValidateAPI')
2123

@@ -187,6 +189,24 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
187189
{ status: 402 }
188190
)
189191
}
192+
193+
if (vertexCredential && getProviderFromModel(model) === 'vertex') {
194+
const vertexCredAccess = await authorizeCredentialUse(request, {
195+
credentialId: vertexCredential,
196+
workflowId,
197+
requireWorkflowIdForInternal: false,
198+
})
199+
if (!vertexCredAccess.ok) {
200+
logger.warn(`[${requestId}] Vertex credential access denied`, {
201+
error: vertexCredAccess.error,
202+
credentialId: vertexCredential,
203+
})
204+
return NextResponse.json(
205+
{ error: vertexCredAccess.error || 'Unauthorized' },
206+
{ status: 401 }
207+
)
208+
}
209+
}
190210
}
191211

192212
const inputStr = convertInputToString(input)

0 commit comments

Comments
 (0)