Skip to content

Commit d93dab4

Browse files
fix(api): align credential mutation gating, provider-outage status, and unique-violation conflicts
1 parent 11e3ece commit d93dab4

5 files changed

Lines changed: 101 additions & 11 deletions

File tree

apps/docs/openapi-v2-resources.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1601,7 +1601,7 @@
16011601
"patch": {
16021602
"operationId": "updateCredential",
16031603
"summary": "Update Credential",
1604-
"description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response.",
1604+
"description": "Rename a credential, change its description, or rotate its stored secret. Requires credential admin \u2014 access to the workspace plus admin rights on the credential itself. Workspace `write` is not required: credentials are gated per credential, not per workspace.\n\nSending a secret field rotates that secret in place: it is re-verified against the provider and re-encrypted, and the display name is preserved. Secrets are never returned in the response. If the provider cannot be reached to verify the new secret, the request returns `503`.\n\nA credential you cannot see answers `404` rather than `403`, so its existence is never disclosed.",
16051605
"tags": ["Credentials"],
16061606
"x-codeSamples": [
16071607
{
@@ -1673,13 +1673,14 @@
16731673
"404": { "$ref": "#/components/responses/NotFound" },
16741674
"409": { "$ref": "#/components/responses/Conflict" },
16751675
"429": { "$ref": "#/components/responses/RateLimited" },
1676-
"500": { "$ref": "#/components/responses/InternalError" }
1676+
"500": { "$ref": "#/components/responses/InternalError" },
1677+
"503": { "$ref": "#/components/responses/ServiceUnavailable" }
16771678
}
16781679
},
16791680
"delete": {
16801681
"operationId": "deleteCredential",
16811682
"summary": "Delete Credential",
1682-
"description": "Delete a credential. Requires credential admin. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.",
1683+
"description": "Delete a credential. Requires credential admin \u2014 access to the workspace plus admin rights on the credential itself; workspace `write` is not required. Blocks and workflows configured against it stop authenticating, and any environment variable it backed is removed.\n\nA credential you cannot see answers `404` rather than `403`.",
16831684
"tags": ["Credentials"],
16841685
"x-codeSamples": [
16851686
{

apps/sim/app/api/v2/credentials/[id]/route.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ describe('PATCH /api/v2/credentials/[id]', () => {
188188
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
189189
mockResolveWorkspaceAccess.mockResolvedValue(null)
190190
mockGetWorkspaceCredential.mockResolvedValue(buildRow())
191+
mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true })
191192
mockPerformUpdateCredential.mockResolvedValue({ success: true })
192193
})
193194

@@ -246,6 +247,35 @@ describe('PATCH /api/v2/credentials/[id]', () => {
246247
expect((await res.json()).error.code).toBe('FORBIDDEN')
247248
})
248249

250+
it('gates on workspace read, leaving admin rights to the per-credential check', async () => {
251+
await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' })
252+
expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(
253+
expect.anything(),
254+
'user-1',
255+
WORKSPACE_ID,
256+
'read'
257+
)
258+
})
259+
260+
it('masks a credential the caller cannot see as 404, not 403', async () => {
261+
mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false })
262+
const res = await callPatch({ workspaceId: WORKSPACE_ID, displayName: 'Renamed' })
263+
expect(res.status).toBe(404)
264+
expect(mockPerformUpdateCredential).not.toHaveBeenCalled()
265+
})
266+
267+
it('503s when the provider is unreachable during a secret rotation', async () => {
268+
mockPerformUpdateCredential.mockResolvedValue({
269+
success: false,
270+
error: 'provider_unavailable',
271+
errorCode: 'validation',
272+
providerErrorCode: 'provider_unavailable',
273+
})
274+
const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'tok' })
275+
expect(res.status).toBe(503)
276+
expect((await res.json()).error.code).toBe('SERVICE_UNAVAILABLE')
277+
})
278+
249279
it('rotates a secret without echoing it back', async () => {
250280
const res = await callPatch({ workspaceId: WORKSPACE_ID, apiToken: 'brand-new-token' })
251281
const body = await res.json()
@@ -268,6 +298,7 @@ describe('DELETE /api/v2/credentials/[id]', () => {
268298
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK)
269299
mockResolveWorkspaceAccess.mockResolvedValue(null)
270300
mockGetWorkspaceCredential.mockResolvedValue(buildRow())
301+
mockGetCredentialActorContext.mockResolvedValue({ member: { role: 'admin' }, isAdmin: true })
271302
mockPerformDeleteCredential.mockResolvedValue({ success: true })
272303
})
273304

@@ -309,6 +340,23 @@ describe('DELETE /api/v2/credentials/[id]', () => {
309340
expect(mockPerformDeleteCredential).not.toHaveBeenCalled()
310341
})
311342

343+
it('gates on workspace read, leaving admin rights to the per-credential check', async () => {
344+
await callDelete()
345+
expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(
346+
expect.anything(),
347+
'user-1',
348+
WORKSPACE_ID,
349+
'read'
350+
)
351+
})
352+
353+
it('masks a credential the caller cannot see as 404, not 403', async () => {
354+
mockGetCredentialActorContext.mockResolvedValue({ member: null, isAdmin: false })
355+
const res = await callDelete()
356+
expect(res.status).toBe(404)
357+
expect(mockPerformDeleteCredential).not.toHaveBeenCalled()
358+
})
359+
312360
it('deletes the credential and acknowledges the id', async () => {
313361
const res = await callDelete()
314362
expect(res.status).toBe(200)

apps/sim/app/api/v2/credentials/[id]/route.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,20 +101,30 @@ export const PATCH = withRouteHandler(async (request: NextRequest, context: Rout
101101
const { id } = parsed.data.params
102102
const { workspaceId, ...changes } = parsed.data.body
103103

104-
const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
104+
/**
105+
* Credential mutations are gated per credential, not per workspace:
106+
* `performUpdateCredential` requires credential admin, and the internal
107+
* surface applies no workspace-level bar at all. Requiring workspace `write`
108+
* here would lock out a credential admin who only holds `read`.
109+
*/
110+
const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
105111
if (access) return v2WorkspaceAccessError(access)
106112

107113
// Tenant-scope the id before the orchestration re-derives access from the
108114
// credential's own workspace.
109115
const existing = await getWorkspaceCredential({ workspaceId, credentialId: id })
110116
if (!existing) return v2Error('NOT_FOUND', 'Credential not found')
111117

118+
const actor = await getCredentialActorContext(id, userId)
119+
if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found')
120+
112121
const result = await performUpdateCredential({ ...changes, credentialId: id, userId, request })
113122

114123
if (!result.success) {
115124
return v2CredentialOrchestrationError(
116125
result.errorCode,
117-
result.error ?? 'Failed to update credential'
126+
result.error ?? 'Failed to update credential',
127+
{ providerUnavailable: result.providerErrorCode === 'provider_unavailable' }
118128
)
119129
}
120130

@@ -151,12 +161,23 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Rou
151161
const { id } = parsed.data.params
152162
const { workspaceId } = parsed.data.query
153163

154-
const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write')
164+
// Gated per credential by `performDeleteCredential`, same as PATCH above.
165+
const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read')
155166
if (access) return v2WorkspaceAccessError(access)
156167

157168
const existing = await getWorkspaceCredential({ workspaceId, credentialId: id })
158169
if (!existing) return v2Error('NOT_FOUND', 'Credential not found')
159170

171+
/**
172+
* A credential the caller cannot see answers 404, matching GET, so a
173+
* workspace member cannot tell an inaccessible credential from a missing one
174+
* and enumerate ids. A credential they *can* see but cannot administer still
175+
* gets the orchestration's 403 — that distinction is not a leak, since GET
176+
* already shows them the credential.
177+
*/
178+
const actor = await getCredentialActorContext(id, userId)
179+
if (!actor.member && !actor.isAdmin) return v2Error('NOT_FOUND', 'Credential not found')
180+
160181
const result = await performDeleteCredential({ credentialId: id, userId, request })
161182
if (!result.success) {
162183
return v2CredentialOrchestrationError(

apps/sim/app/api/v2/custom-tools/route.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,18 @@ describe('POST /api/v2/custom-tools', () => {
238238
expect((await res.json()).error.code).toBe('CONFLICT')
239239
})
240240

241+
it('409s when the unique index rejects the loser of a title race', async () => {
242+
const pgError = Object.assign(new Error('duplicate key value violates unique constraint'), {
243+
code: '23505',
244+
})
245+
mockUpsertCustomTools.mockRejectedValue(pgError)
246+
247+
const res = await callCreate(VALID_BODY)
248+
249+
expect(res.status).toBe(409)
250+
expect((await res.json()).error.code).toBe('CONFLICT')
251+
})
252+
241253
it('creates the tool and returns 201 with the single tool', async () => {
242254
const res = await callCreate(VALID_BODY)
243255
const body = await res.json()

apps/sim/app/api/v2/custom-tools/utils.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import type { customTools } from '@sim/db/schema'
2-
import { getErrorMessage } from '@sim/utils/errors'
2+
import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors'
33
import type { NextResponse } from 'next/server'
44
import type { V2CustomTool } from '@/lib/api/contracts/v2/custom-tools'
55
import { v2Error } from '@/app/api/v2/lib/response'
66

77
/** Shared serialization + error mapping for the v2 custom tool surface. */
88

99
/**
10-
* `upsertCustomTools` reports a title collision as a thrown Error, and the unique
11-
* index behind it fires on the race the pre-check cannot cover (two concurrent
12-
* creates of the same title both pass the check, then one insert loses). Classify
13-
* it as a conflict so that race surfaces as 409 rather than a generic 500.
10+
* Classifies a title collision as a conflict so it surfaces as 409 rather than a
11+
* generic 500. Two distinct failures reach here and both must be covered:
12+
*
13+
* - `upsertCustomTools` throws its own message when its in-transaction duplicate
14+
* `SELECT` finds one.
15+
* - Under a concurrent create or rename, both callers pass that `SELECT` too, and
16+
* the loser is rejected by `custom_tools_workspace_title_unique` as a raw
17+
* Postgres `23505` — whose message matches nothing, which is exactly the race
18+
* the message check alone cannot see.
1419
*/
1520
export function v2CustomToolWriteError(error: unknown): NextResponse | null {
21+
if (getPostgresErrorCode(error) === '23505') {
22+
return v2Error('CONFLICT', 'A custom tool with that title already exists in this workspace')
23+
}
1624
const message = getErrorMessage(error, '')
1725
if (/already exists in this workspace/i.test(message)) {
1826
return v2Error('CONFLICT', message)

0 commit comments

Comments
 (0)