Skip to content

Commit d03fad0

Browse files
fix(credentials): close TOCTOU and restore typed errors after consolidation
- Add inner duplicate-guard inside the create transaction (DuplicateCredentialError) to close the race that the outer findExistingCredentialBySource leaves open. service_account rows have no DB-level unique index on (workspaceId, providerId, displayName), so this is the actual safety net. Tx-internal check applies to Google + env_workspace too — race-safety win for all credential types. - Re-emit {code: 'duplicate_display_name', error: ...} on conflict so the form's ERROR_MESSAGES.duplicate_display_name mapping is reachable again. - Thread Atlassian-specific audit metadata (atlassianDomain, atlassianCloudId) back into recordAudit; consolidation had dropped them. - Use ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID constant in contract superRefine. - Drop `error: any` in catch in favor of `error: unknown` + getPostgresErrorCode.
1 parent e017f19 commit d03fad0

2 files changed

Lines changed: 78 additions & 16 deletions

File tree

apps/sim/app/api/credentials/route.ts

Lines changed: 76 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
22
import { db } from '@sim/db'
33
import { account, credential, credentialMember, workspace } from '@sim/db/schema'
44
import { createLogger } from '@sim/logger'
5+
import { getPostgresErrorCode } from '@sim/utils/errors'
56
import { generateId } from '@sim/utils/id'
67
import { and, eq } from 'drizzle-orm'
78
import { type NextRequest, NextResponse } from 'next/server'
@@ -33,6 +34,19 @@ import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
3334

3435
const logger = createLogger('CredentialsAPI')
3536

37+
/**
38+
* Thrown by the inner duplicate guard inside the create transaction when a
39+
* concurrent request slipped a row in between the outer existence check and
40+
* our INSERT. The catch maps this to a 409 with a typed `code` so the UI can
41+
* map to a friendly message.
42+
*/
43+
class DuplicateCredentialError extends Error {
44+
constructor() {
45+
super('duplicate_display_name')
46+
this.name = 'DuplicateCredentialError'
47+
}
48+
}
49+
3650
interface ExistingCredentialSourceParams {
3751
workspaceId: string
3852
type: 'oauth' | 'env_workspace' | 'env_personal' | 'service_account'
@@ -43,11 +57,16 @@ interface ExistingCredentialSourceParams {
4357
providerId?: string | null
4458
}
4559

46-
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
60+
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0]
61+
62+
async function findExistingCredentialBySourceWith(
63+
exec: DbOrTx,
64+
params: ExistingCredentialSourceParams
65+
) {
4766
const { workspaceId, type, accountId, envKey, envOwnerUserId, displayName, providerId } = params
4867

4968
if (type === 'oauth' && accountId) {
50-
const [row] = await db
69+
const [row] = await exec
5170
.select()
5271
.from(credential)
5372
.where(
@@ -62,7 +81,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
6281
}
6382

6483
if (type === 'env_workspace' && envKey) {
65-
const [row] = await db
84+
const [row] = await exec
6685
.select()
6786
.from(credential)
6887
.where(
@@ -77,7 +96,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
7796
}
7897

7998
if (type === 'env_personal' && envKey && envOwnerUserId) {
80-
const [row] = await db
99+
const [row] = await exec
81100
.select()
82101
.from(credential)
83102
.where(
@@ -93,7 +112,7 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
93112
}
94113

95114
if (type === 'service_account' && displayName && providerId) {
96-
const [row] = await db
115+
const [row] = await exec
97116
.select()
98117
.from(credential)
99118
.where(
@@ -111,6 +130,17 @@ async function findExistingCredentialBySource(params: ExistingCredentialSourcePa
111130
return null
112131
}
113132

133+
async function findExistingCredentialBySource(params: ExistingCredentialSourceParams) {
134+
return findExistingCredentialBySourceWith(db, params)
135+
}
136+
137+
async function findExistingCredentialBySourceTx(
138+
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
139+
params: ExistingCredentialSourceParams
140+
) {
141+
return findExistingCredentialBySourceWith(tx, params)
142+
}
143+
114144
export const GET = withRouteHandler(async (request: NextRequest) => {
115145
const requestId = generateRequestId()
116146
const session = await getSession()
@@ -278,6 +308,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
278308
const resolvedEnvKey: string | null = envKey ? normalizeCredentialEnvKey(envKey) : null
279309
let resolvedEnvOwnerUserId: string | null = null
280310
let resolvedEncryptedServiceAccountKey: string | null = null
311+
const extraAuditMetadata: Record<string, unknown> = {}
281312

282313
if (type === 'oauth') {
283314
const [accountRow] = await db
@@ -341,6 +372,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
341372
})
342373
const { encrypted } = await encryptSecret(blob)
343374
resolvedEncryptedServiceAccountKey = encrypted
375+
extraAuditMetadata.atlassianDomain = normalizedDomain
376+
extraAuditMetadata.atlassianCloudId = validation.cloudId
344377
} else {
345378
if (!serviceAccountJson) {
346379
return NextResponse.json(
@@ -472,6 +505,22 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
472505
.limit(1)
473506

474507
await db.transaction(async (tx) => {
508+
// Race-safety re-check: the outer findExistingCredentialBySource ran outside
509+
// the transaction. Service_account rows have no DB-level unique index on
510+
// (workspaceId, providerId, displayName), so a concurrent request could
511+
// have inserted a duplicate after our outer check but before this insert.
512+
// Re-check here and abort the tx if so. The catch maps this to a 409.
513+
const innerExisting = await findExistingCredentialBySourceTx(tx, {
514+
workspaceId,
515+
type,
516+
accountId: resolvedAccountId,
517+
envKey: resolvedEnvKey,
518+
envOwnerUserId: resolvedEnvOwnerUserId,
519+
displayName: resolvedDisplayName,
520+
providerId: resolvedProviderId,
521+
})
522+
if (innerExisting) throw new DuplicateCredentialError()
523+
475524
await tx.insert(credential).values({
476525
id: credentialId,
477526
workspaceId,
@@ -552,12 +601,13 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
552601
metadata: {
553602
credentialType: type,
554603
providerId: resolvedProviderId,
604+
...extraAuditMetadata,
555605
},
556606
request,
557607
})
558608

559609
return NextResponse.json({ credential: created }, { status: 201 })
560-
} catch (error: any) {
610+
} catch (error: unknown) {
561611
if (error instanceof AtlassianValidationError) {
562612
logger.warn(`[${requestId}] Atlassian credential rejected: ${error.code}`, {
563613
code: error.code,
@@ -566,30 +616,42 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
566616
})
567617
return NextResponse.json({ code: error.code, error: error.code }, { status: 400 })
568618
}
569-
if (error?.code === '23505') {
619+
if (error instanceof DuplicateCredentialError) {
620+
return NextResponse.json(
621+
{
622+
code: 'duplicate_display_name',
623+
error: 'A credential with that name already exists in this workspace.',
624+
},
625+
{ status: 409 }
626+
)
627+
}
628+
const pgCode = getPostgresErrorCode(error)
629+
if (pgCode === '23505') {
570630
return NextResponse.json(
571631
{ error: 'A credential with this source already exists' },
572632
{ status: 409 }
573633
)
574634
}
575-
if (error?.code === '23503') {
635+
if (pgCode === '23503') {
576636
return NextResponse.json(
577637
{ error: 'Invalid credential reference or membership target' },
578638
{ status: 400 }
579639
)
580640
}
581-
if (error?.code === '23514') {
641+
if (pgCode === '23514') {
582642
return NextResponse.json(
583643
{ error: 'Credential source data failed validation checks' },
584644
{ status: 400 }
585645
)
586646
}
647+
const errAsRecord =
648+
typeof error === 'object' && error !== null ? (error as Record<string, unknown>) : {}
587649
logger.error(`[${requestId}] Credential create failure details`, {
588-
code: error?.code,
589-
detail: error?.detail,
590-
constraint: error?.constraint,
591-
table: error?.table,
592-
message: error?.message,
650+
code: pgCode,
651+
detail: errAsRecord.detail,
652+
constraint: errAsRecord.constraint,
653+
table: errAsRecord.table,
654+
message: errAsRecord.message,
593655
})
594656
logger.error(`[${requestId}] Failed to create credential`, error)
595657
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })

apps/sim/lib/api/contracts/credentials.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { z } from 'zod'
22
import { defineRouteContract } from '@/lib/api/contracts/types'
3-
import type { OAuthProvider } from '@/lib/oauth/types'
3+
import { ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID, type OAuthProvider } from '@/lib/oauth/types'
44

55
const ENV_VAR_NAME_REGEX = /^[A-Za-z0-9_]+$/
66

@@ -149,7 +149,7 @@ export const createCredentialBodySchema = z
149149
}
150150

151151
if (data.type === 'service_account') {
152-
if (data.providerId === 'atlassian-service-account') {
152+
if (data.providerId === ATLASSIAN_SERVICE_ACCOUNT_PROVIDER_ID) {
153153
if (!data.apiToken) {
154154
ctx.addIssue({
155155
code: z.ZodIssueCode.custom,

0 commit comments

Comments
 (0)