Skip to content

Commit b96b952

Browse files
committed
fix(credentials): lock keyless subject checks through the advisory-lock helper and refuse on unreadable siblings
assertWorkloadIdentitySubjectUnclaimed now takes a DbTransaction and locks through acquireAdvisoryXactLock, so the lock carries its SQLCommenter tag and cannot be taken on the pool by mistake. A sibling keyless credential whose secret does not decrypt or parse used to throw a generic error, failing every keyless create or rotation for the owner with a 500. Since it might hold the subject, the save is now refused as a conflict naming that credential, with a warning logged, and stored subjects are read with a schema safeParse.
1 parent 638b457 commit b96b952

2 files changed

Lines changed: 74 additions & 13 deletions

File tree

‎apps/sim/lib/credentials/__integration__/workload-identity-subject-reuse.integration.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ vi.mock('@/lib/credentials/workload-identity/issuer', async (importOriginal) =>
1919
mintWorkloadIdentityToken: async ({ subject }: { subject: string }) => `token-for:${subject}`,
2020
}))
2121

22+
import { encryptSecret } from '@/lib/core/security/encryption'
2223
import { updateCredentialRecord } from '@/lib/credentials/orchestration'
2324
import { createCredentialRecord } from '@/lib/credentials/orchestration/credential-create'
2425
import { issueWorkloadIdentitySubject } from '@/lib/credentials/workload-identity/subject'
@@ -191,4 +192,31 @@ describe('reusing a keyless trust setup', () => {
191192

192193
expect((await create('Successor', keyless(setup))).success).toBe(true)
193194
})
195+
196+
it.each([
197+
['ciphertext that does not decrypt', async () => 'not-a-ciphertext'],
198+
['a secret that is not JSON', async () => (await encryptSecret('not json')).encrypted],
199+
])(
200+
'refuses a new subject while a sibling holds %s, instead of failing the save',
201+
async (_case, sealed) => {
202+
const unreadableId = generateId()
203+
await db.insert(credential).values({
204+
id: unreadableId,
205+
workspaceId,
206+
type: 'service_account',
207+
providerId: DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID,
208+
displayName: 'Unreadable sibling',
209+
encryptedServiceAccountKey: await sealed(),
210+
createdBy: userId,
211+
})
212+
try {
213+
const result = await create(`Blocked by ${unreadableId}`, keyless(grant()))
214+
215+
expect(result).toMatchObject({ success: false, errorCode: 'conflict' })
216+
expect(result.error).toContain('Unreadable sibling')
217+
} finally {
218+
await db.delete(credential).where(eq(credential.id, unreadableId))
219+
}
220+
}
221+
)
194222
})

‎apps/sim/lib/credentials/workload-identity/subject.ts‎

Lines changed: 46 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
import { createHmac } from 'node:crypto'
22
import { credential } from '@sim/db/schema'
3+
import { createLogger } from '@sim/logger'
34
import { safeCompare } from '@sim/security/compare'
45
import { generateId } from '@sim/utils/id'
5-
import { isRecordLike } from '@sim/utils/object'
6-
import { and, inArray, isNull, ne, sql } from 'drizzle-orm'
6+
import { and, inArray, isNull, ne } from 'drizzle-orm'
7+
import { z } from 'zod'
78
import { env } from '@/lib/core/config/env'
89
import type { ResourceScope } from '@/lib/core/resource-scope'
910
import { resourceScopeCondition } from '@/lib/core/resource-scope.server'
1011
import { decryptSecret } from '@/lib/core/security/encryption'
1112
import type { WorkloadIdentityProvider } from '@/lib/credentials/workload-identity/audiences'
1213
import type { WorkloadIdentityOwner } from '@/lib/credentials/workload-identity/issuer'
13-
import type { DbOrTx } from '@/lib/db/types'
14+
import { acquireAdvisoryXactLock } from '@/lib/db/advisory-locks'
15+
import type { DbTransaction } from '@/lib/db/types'
1416
import {
1517
AWS_SERVICE_ACCOUNT_PROVIDER_ID,
1618
DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID,
1719
} from '@/lib/oauth/types'
1820

21+
const logger = createLogger('WorkloadIdentitySubject')
22+
1923
/**
2024
* Credential-scoped workload-identity subjects.
2125
*
@@ -163,6 +167,22 @@ const KEYLESS_PROVIDER_IDS = [
163167
DATABRICKS_SERVICE_ACCOUNT_PROVIDER_ID,
164168
]
165169

170+
/** The part of an AWS or Databricks stored secret that can hold a bound subject. */
171+
const storedSubjectSchema = z.object({
172+
config: z.object({ subject: z.string().optional() }),
173+
})
174+
175+
/** The subject a stored secret holds, `undefined` for a keyed mode, or `null` when unreadable. */
176+
async function readStoredSubject(encrypted: string): Promise<string | undefined | null> {
177+
try {
178+
const { decrypted } = await decryptSecret(encrypted)
179+
const parsed = storedSubjectSchema.safeParse(JSON.parse(decrypted))
180+
return parsed.success ? parsed.data.config.subject : null
181+
} catch {
182+
return null
183+
}
184+
}
185+
166186
/**
167187
* Refuses a subject that another live credential of the same owner already
168188
* holds. A proof stays valid for a day and is not tied to one credential, so
@@ -171,21 +191,28 @@ const KEYLESS_PROVIDER_IDS = [
171191
*
172192
* The subject lives only inside each credential's encrypted secret, so this
173193
* decrypts the owner's keyless credentials (one owner holds few) rather than
174-
* relying on a unique index. Call it inside the transaction that writes the
194+
* relying on a unique index. It runs inside the transaction that writes the
175195
* credential: the advisory lock holds concurrent saves of one subject until
176-
* commit, so the later save sees the earlier row.
196+
* commit, so the later save sees the earlier row. A sibling whose secret cannot
197+
* be read might hold the subject, so it refuses the save rather than skip it.
177198
*
178199
* @param credentialId The credential being reconnected, which may keep its own subject.
179200
*/
180201
export async function assertWorkloadIdentitySubjectUnclaimed(
181-
tx: DbOrTx,
202+
tx: DbTransaction,
182203
input: { subject: string; scope: ResourceScope; credentialId?: string }
183204
): Promise<void> {
184-
await tx.execute(
185-
sql`SELECT pg_advisory_xact_lock(hashtextextended(${`workload-identity-subject:${input.subject}`}, 0))`
205+
await acquireAdvisoryXactLock(
206+
tx,
207+
'workload_identity_subject',
208+
`workload-identity-subject:${input.subject}`
186209
)
187210
const rows = await tx
188-
.select({ encryptedServiceAccountKey: credential.encryptedServiceAccountKey })
211+
.select({
212+
id: credential.id,
213+
displayName: credential.displayName,
214+
encryptedServiceAccountKey: credential.encryptedServiceAccountKey,
215+
})
189216
.from(credential)
190217
.where(
191218
and(
@@ -197,10 +224,16 @@ export async function assertWorkloadIdentitySubjectUnclaimed(
197224
)
198225
for (const row of rows) {
199226
if (!row.encryptedServiceAccountKey) continue
200-
const { decrypted } = await decryptSecret(row.encryptedServiceAccountKey)
201-
const stored: unknown = JSON.parse(decrypted)
202-
const config = isRecordLike(stored) ? stored.config : undefined
203-
if (isRecordLike(config) && config.subject === input.subject) {
227+
const storedSubject = await readStoredSubject(row.encryptedServiceAccountKey)
228+
if (storedSubject === null) {
229+
logger.warn('Unreadable keyless credential blocks a subject uniqueness check', {
230+
credentialId: row.id,
231+
})
232+
throw new WorkloadIdentitySubjectError(
233+
`Sim could not read the saved credential "${row.displayName}", so it cannot confirm this trust setup is unused. Reconnect or delete that credential, then try again.`
234+
)
235+
}
236+
if (storedSubject === input.subject) {
204237
throw new WorkloadIdentitySubjectError(
205238
'Another credential already uses this trust setup. Start the keyless connection again.'
206239
)

0 commit comments

Comments
 (0)